Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:50:06 +08:00
commit 5e3ca965d9
68 changed files with 11257 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# Odoo Instance Configuration (for backend server)
ODOO_URL=https://your-instance.odoo.com
ODOO_DB=your-database-name
ODOO_USERNAME=your-username
ODOO_API_KEY=your-api-key
# Primary Model (use x_ prefix for Odoo Studio models)
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
# Frontend: API endpoint (if frontend and backend are separate)
VITE_API_URL=http://localhost:3000

View File

@@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
# Environment
.env
.env.local
.env.production

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#667eea" />
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
<link rel="manifest" href="/manifest.json" />
<title>{{PROJECT_NAME}}</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"name": "{{PROJECT_NAME}}",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.1",
"vite-plugin-pwa": "^0.20.0"
}
}

View File

@@ -0,0 +1,49 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
},
manifest: {
name: '{{PROJECT_NAME}}',
short_name: '{{PROJECT_NAME}}',
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
theme_color: '#667eea',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true
}
}
}
});

View File

@@ -0,0 +1,74 @@
<template>
<div class="app">
<div class="container">
<h1>📋 {{PROJECT_NAME}}</h1>
<OfflineBanner />
<nav class="nav">
<router-link
to="/"
:class="{ active: $route.path === '/' }"
>
Add {{MODEL_DISPLAY_NAME}}
</router-link>
<router-link
to="/list"
:class="{ active: $route.path === '/list' }"
>
View All
</router-link>
</nav>
<router-view />
</div>
</div>
</template>
<script setup>
import OfflineBanner from './components/OfflineBanner.vue';
</script>
<style scoped>
.app {
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 16px;
}
h1 {
color: white;
text-align: center;
margin-bottom: 30px;
font-size: 2.5em;
}
.nav {
display: flex;
gap: 10px;
margin-bottom: 30px;
background: white;
border-radius: 10px;
padding: 5px;
}
.nav a {
flex: 1;
text-align: center;
padding: 12px;
text-decoration: none;
color: #667eea;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}
.nav a.active {
background: #667eea;
color: white;
}
</style>

View File

@@ -0,0 +1,123 @@
/**
* Odoo API Client for React
* Communicates with backend server that proxies to Odoo
*/
class OdooAPI {
constructor() {
this.apiUrl = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api/odoo`
: '/api/odoo';
}
/**
* Call the server-side API
* @param {string} action
* @param {any} data
* @returns {Promise<any>}
*/
async callApi(action, data) {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action, data })
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'API Error');
}
return result;
}
/**
* Create a new record in specified model
* @param {string} model - Odoo model name
* @param {Record<string, any>} fields - Record fields
* @returns {Promise<number>} - Created record ID
*/
async createRecord(model, fields) {
const result = await this.callApi('create', { model, fields });
return result.id;
}
/**
* Search and read records from specified model
* @param {string} model - Odoo model name
* @param {any[]} domain - Odoo domain filter
* @param {string[]} fields - Fields to retrieve
* @returns {Promise<any[]>} - Array of records
*/
async searchRecords(model, domain = [], fields = []) {
const result = await this.callApi('search', { model, domain, fields });
return result.results;
}
/**
* Generic search_read for any model
* @param {string} model
* @param {any[]} domain
* @param {string[]} fields
* @returns {Promise<any[]>}
*/
async searchModel(model, domain = [], fields = []) {
const result = await this.callApi('search_model', { model, domain, fields });
return result.results;
}
/**
* Fetch partner list (common operation)
* @returns {Promise<Array<{id:number, display_name:string}>>}
*/
async fetchPartners() {
return await this.searchModel('res.partner', [], ['id', 'display_name']);
}
/**
* Format a many2one field value
* @param {number|string|null|undefined} id
* @returns {number|false}
*/
formatMany2one(id) {
return id ? Number(id) : false;
}
/**
* Format a many2many field using the (6,0,[ids]) command
* @param {Array<number|string>} ids
* @returns {any[]}
*/
formatMany2many(ids) {
if (!Array.isArray(ids) || ids.length === 0) return [];
return [[6, 0, ids.map((i) => Number(i))]];
}
/**
* Update a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @param {Record<string, any>} values - Fields to update
* @returns {Promise<boolean>}
*/
async updateRecord(model, id, values) {
const result = await this.callApi('update', { model, id, values });
return result.result;
}
/**
* Delete a record
* @param {string} model - Odoo model name
* @param {number} id - Record ID
* @returns {Promise<boolean>}
*/
async deleteRecord(model, id) {
const result = await this.callApi('delete', { model, id });
return result.result;
}
}
export const odooClient = new OdooAPI();

View File

@@ -0,0 +1,48 @@
<template>
<div v-if="isOffline" class="offline-banner">
📡 Offline Mode - Data will be synced when you're back online
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const isOffline = ref(!navigator.onLine);
const handleOnline = () => isOffline.value = false;
const handleOffline = () => isOffline.value = true;
onMounted(() => {
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
});
onUnmounted(() => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
});
</script>
<style scoped>
.offline-banner {
background: #e3f2fd;
color: #1565c0;
padding: 12px 20px;
border-radius: 10px;
margin-bottom: 20px;
text-align: center;
font-weight: 600;
border: 2px solid #64b5f6;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,224 @@
import { ref, onMounted, onUnmounted } from 'vue';
import { odooClient } from '../api/odoo';
// Cache configuration
const CACHE_KEY = '{{MODEL_NAME}}_cache_v1';
const CACHE_META_KEY = '{{MODEL_NAME}}_cache_meta_v1';
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
// Helper functions for localStorage
function loadFromStorage() {
try {
const cachedData = localStorage.getItem(CACHE_KEY);
const meta = localStorage.getItem(CACHE_META_KEY);
if (cachedData && meta) {
const records = JSON.parse(cachedData);
const metaData = JSON.parse(meta);
const now = Date.now();
const isStale = now - metaData.lastSyncTime > CACHE_DURATION_MS;
return { records, meta: { ...metaData, isStale } };
}
} catch (e) {
console.warn('Failed to load cache:', e);
}
return {
records: [],
meta: {
lastSyncTime: 0,
lastRecordId: 0,
recordCount: 0,
isStale: true
}
};
}
function saveToStorage(records, meta) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(records));
localStorage.setItem(CACHE_META_KEY, JSON.stringify({
lastSyncTime: meta.lastSyncTime || Date.now(),
lastRecordId: meta.lastRecordId || 0,
recordCount: records.length,
isStale: false
}));
} catch (e) {
console.warn('Failed to save cache:', e);
}
}
export function useTaskCache() {
const records = ref([]);
const loading = ref(false);
const syncing = ref(false);
const error = ref('');
const meta = ref({
lastSyncTime: 0,
lastRecordId: 0,
recordCount: 0,
isStale: true
});
let syncInterval = null;
// Sync function
const sync = async (forceFullRefresh = false) => {
syncing.value = true;
error.value = '';
try {
const fields = ['id', 'x_name'];
let domain = [];
let fetchedRecords = [];
if (!forceFullRefresh && meta.value.lastRecordId > 0) {
domain = [['id', '>', meta.value.lastRecordId]];
try {
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
} catch (err) {
console.warn('Incremental fetch failed:', err);
forceFullRefresh = true;
}
}
if (forceFullRefresh || meta.value.lastRecordId === 0) {
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
}
let mergedRecords;
if (forceFullRefresh || meta.value.lastRecordId === 0) {
mergedRecords = fetchedRecords;
} else {
const existingIds = new Set(records.value.map(r => r.id));
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
mergedRecords = [...records.value, ...newRecords];
}
mergedRecords.sort((a, b) => a.id - b.id);
const lastRecordId = mergedRecords.length > 0
? Math.max(...mergedRecords.map(r => r.id))
: 0;
const newMeta = {
lastSyncTime: Date.now(),
lastRecordId,
recordCount: mergedRecords.length,
isStale: false
};
saveToStorage(mergedRecords, newMeta);
records.value = mergedRecords;
meta.value = newMeta;
} catch (err) {
console.error('Sync failed:', err);
error.value = err.message || 'Failed to sync data';
} finally {
syncing.value = false;
}
};
// Initialize
const initialize = async () => {
const cachedState = loadFromStorage();
if (cachedState.records.length > 0) {
records.value = cachedState.records;
meta.value = cachedState.meta;
if (cachedState.meta.isStale) {
await sync();
}
} else {
loading.value = true;
await sync(true);
loading.value = false;
}
// Set up periodic sync
syncInterval = setInterval(() => {
sync();
}, SYNC_INTERVAL_MS);
};
// Cleanup
const cleanup = () => {
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
};
// Force refresh
const forceRefresh = async () => {
await sync(true);
};
// Create record
const createRecord = async (fields) => {
try {
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
const newRecord = { id, ...fields };
records.value = [...records.value, newRecord];
await sync();
return id;
} catch (err) {
console.error('Failed to create record:', err);
throw err;
}
};
// Update record
const updateRecord = async (id, values) => {
try {
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
records.value = records.value.map(r =>
r.id === id ? { ...r, ...values } : r
);
await sync();
return true;
} catch (err) {
console.error('Failed to update record:', err);
throw err;
}
};
// Delete record
const deleteRecord = async (id) => {
try {
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
records.value = records.value.filter(r => r.id !== id);
return true;
} catch (err) {
console.error('Failed to delete record:', err);
throw err;
}
};
// Auto-initialize and cleanup on component lifecycle
onMounted(() => {
initialize();
});
onUnmounted(() => {
cleanup();
});
return {
records,
loading,
syncing,
error,
meta,
sync,
forceRefresh,
createRecord,
updateRecord,
deleteRecord
};
}

View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import AddPage from './pages/AddPage.vue';
import ListPage from './pages/ListPage.vue';
import './style.css';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: AddPage },
{ path: '/list', component: ListPage }
]
});
createApp(App)
.use(router)
.mount('#app');

View File

@@ -0,0 +1,161 @@
<template>
<form @submit.prevent="handleSubmit" class="form">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
v-model="name"
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
required
/>
</div>
<div v-if="message" :class="['message', { error: message.includes('❌') }]">
{{ message }}
</div>
<div class="button-group">
<button type="submit" :disabled="loading">
{{ loading ? '⏳ Adding...' : ' Add {{MODEL_DISPLAY_NAME}}' }}
</button>
<button
v-if="isOnline"
type="button"
class="refresh-btn"
@click="forceRefresh"
>
🔄 Refresh Data
</button>
</div>
</form>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useTaskCache } from '../composables/useTaskCache';
const { createRecord, forceRefresh } = useTaskCache();
const name = ref('');
const loading = ref(false);
const message = ref('');
const isOnline = computed(() => navigator.onLine);
const handleSubmit = async () => {
if (!name.value.trim()) {
message.value = '⚠️ Please fill in the name field';
return;
}
loading.value = true;
message.value = '';
try {
const payload = { x_name: name.value };
await createRecord(payload);
if (navigator.onLine) {
message.value = '✅ {{MODEL_DISPLAY_NAME}} added successfully!';
} else {
message.value = '✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.';
}
name.value = '';
} catch (error) {
message.value = `❌ Error: ${error.message}`;
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.form {
background: white;
padding: 24px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
flex: 1;
min-width: 150px;
padding: 15px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
button:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.refresh-btn {
background: #f0f0f0;
color: #667eea;
flex: 0 1 auto;
}
.refresh-btn:hover:not(:disabled) {
background: #e0e0e0;
color: #5568d3;
}
.message {
padding: 12px;
border-radius: 8px;
margin-bottom: 15px;
background: #d4edda;
color: #155724;
text-align: center;
}
.message.error {
background: #f8d7da;
color: #721c24;
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="list-container">
<div class="list-header">
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
<div class="header-actions">
<input
type="search"
v-model="searchTerm"
placeholder="Search..."
class="search-input"
/>
<button
class="refresh-btn-small"
@click="forceRefresh"
:disabled="syncing"
>
{{ syncing ? '⏳' : '🔄' }}
</button>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="filteredRecords.length === 0" class="empty">
{{ searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!' }}
</div>
<div v-else class="record-list">
<div
v-for="record in filteredRecords"
:key="record.id"
class="record-card"
>
<div class="record-content">
<h3>{{ record.x_name }}</h3>
<p class="record-meta">ID: {{ record.id }}</p>
</div>
<div class="record-actions">
<button class="delete-btn" @click="handleDelete(record.id)">
🗑️
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useTaskCache } from '../composables/useTaskCache';
const { records, loading, syncing, forceRefresh, deleteRecord } = useTaskCache();
const searchTerm = ref('');
const filteredRecords = computed(() => {
return records.value.filter(record =>
record.x_name?.toLowerCase().includes(searchTerm.value.toLowerCase())
);
});
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
return;
}
try {
await deleteRecord(id);
} catch (error) {
alert(`Failed to delete: ${error.message}`);
}
};
</script>
<style scoped>
.list-container {
background: white;
padding: 24px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
h2 {
margin: 0;
color: #333;
font-size: 1.5em;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.search-input {
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
min-width: 200px;
}
.search-input:focus {
outline: none;
border-color: #667eea;
}
.refresh-btn-small {
padding: 10px 15px;
background: #f0f0f0;
color: #667eea;
border: none;
border-radius: 8px;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
}
.refresh-btn-small:hover:not(:disabled) {
background: #e0e0e0;
transform: rotate(360deg);
}
.refresh-btn-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading,
.empty {
text-align: center;
padding: 40px;
color: #666;
font-size: 16px;
}
.record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border: 2px solid #e0e0e0;
border-radius: 10px;
transition: all 0.3s;
}
.record-card:hover {
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.record-content {
flex: 1;
}
.record-content h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 1.1em;
}
.record-meta {
margin: 0;
color: #666;
font-size: 0.9em;
}
.record-actions {
display: flex;
gap: 8px;
}
.delete-btn {
padding: 8px 12px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #feb2b2;
border-radius: 6px;
font-size: 18px;
cursor: pointer;
transition: all 0.3s;
}
.delete-btn:hover {
background: #fed7d7;
}
</style>

View File

@@ -0,0 +1,29 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
Cantarell, 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
#app {
width: 100%;
min-height: 100vh;
}