fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删 - AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3 - 化学品 / Grocy 实例对接 + 库存镜像同步 - 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护 - 月度报表:Excel 6 sheet + PDF - PWA:manifest / SW / 离线缓存 / iOS 引导 - 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿 - 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就 - 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等 - 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications) - 部署:宝塔面板文档 + PM2 + Nginx
327 lines
14 KiB
Vue
327 lines
14 KiB
Vue
<template>
|
||
<AppLayout>
|
||
<div class="head">
|
||
<h1 class="title">新建洗车记录</h1>
|
||
<div class="head-actions">
|
||
<button type="button" class="btn btn-ghost" @click="onAiRecognize" :disabled="aiBusy">
|
||
<span v-if="aiBusy">识别中…</span>
|
||
<span v-else>📷 AI 识别</span>
|
||
</button>
|
||
<router-link to="/washes" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||
</div>
|
||
</div>
|
||
|
||
<AiFallbackModal :show="ai.showFallback" :image-url="ai.fallback?.preview_url" @cancel="ai.cancelFallback()" @confirm="onManualConfirm">
|
||
<div class="grid">
|
||
<div>
|
||
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||
<input v-model="form.wash_date" type="date" class="input" required />
|
||
</div>
|
||
<div>
|
||
<label class="label">类型 <span class="text-danger">*</span></label>
|
||
<select v-model="form.wash_type" class="select" required>
|
||
<option value="quick">快速(15-30 分钟)</option>
|
||
<option value="full">标准(30-60 分钟)</option>
|
||
<option value="detail">精洗(1-2 小时)</option>
|
||
<option value="other">其他</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="label">车辆</label>
|
||
<select v-model="form.vehicle_id" class="select">
|
||
<option value="">不指定</option>
|
||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="label">位置</label>
|
||
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||
</div>
|
||
<div>
|
||
<label class="label">花费 <span class="text-danger">*</span></label>
|
||
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label class="label">备注</label>
|
||
<textarea v-model="form.notes" class="input" rows="2" placeholder="看着图填,看不清的留空"></textarea>
|
||
</div>
|
||
</AiFallbackModal>
|
||
|
||
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||
<div class="grid">
|
||
<div>
|
||
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||
<input v-model="form.wash_date" type="date" class="input" required />
|
||
</div>
|
||
<div>
|
||
<label class="label">类型 <span class="text-danger">*</span></label>
|
||
<select v-model="form.wash_type" class="select" required>
|
||
<option value="quick">快速(15-30 分钟)</option>
|
||
<option value="full">标准(30-60 分钟)</option>
|
||
<option value="detail">精洗(1-2 小时)</option>
|
||
<option value="other">其他</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="label">车辆</label>
|
||
<select v-model="form.vehicle_id" class="select">
|
||
<option value="">不指定</option>
|
||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="label">位置</label>
|
||
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||
</div>
|
||
<div>
|
||
<label class="label">花费 (¥) <span class="text-danger">*</span></label>
|
||
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||
</div>
|
||
<div>
|
||
<label class="label">耗时 (分钟)</label>
|
||
<input v-model.number="form.duration_min" type="number" min="0" class="input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label class="label">备注</label>
|
||
<textarea v-model="form.notes" class="textarea" rows="2" placeholder="可选"></textarea>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label class="label">化学品使用(可选)</label>
|
||
<div class="chem-list">
|
||
<div v-for="(c, i) in chemRows" :key="i" class="chem-row">
|
||
<div class="chem-picker-col">
|
||
<ChemPicker
|
||
v-model="c.chemical_id"
|
||
:chemicals="chemicals"
|
||
:placeholder="availableUnits.length === 0 ? '化学品搜索…' : '搜索化学品(名称/分类)…'"
|
||
@change="onChemChange(i, $event)"
|
||
/>
|
||
</div>
|
||
<div class="chem-amount-col">
|
||
<select v-model="c.unit" class="select chem-unit" :disabled="!c.chemical_id">
|
||
<option v-for="u in availableUnits" :key="u.id" :value="u.name">
|
||
{{ u.name }}
|
||
</option>
|
||
</select>
|
||
<input v-model.number="c.amount" type="number" step="0.01" min="0" placeholder="用量" class="input chem-amt" :disabled="!c.chemical_id" />
|
||
</div>
|
||
<span class="chem-equiv" v-if="c.chemical_id && c.amount > 0">
|
||
= {{ computedStockAmount(c) }} {{ stockUnit(c) }}
|
||
</span>
|
||
<button type="button" class="btn btn-ghost btn-sm del-btn" @click="chemRows.splice(i, 1)">×</button>
|
||
</div>
|
||
<button type="button" class="btn btn-ghost btn-sm" @click="chemRows.push({ chemical_id: '', unit: '毫升', amount: 0 })">+ 添加化学品</button>
|
||
</div>
|
||
<p class="text-mute sm mt-2">
|
||
💡 输入单位可任选(如「0.5 加仑」「100 毫升」),系统自动换算成 Grocy 库存单位(最小精度 = 毫升)。
|
||
<br>
|
||
💡 单位换算关系来自 Grocy product 的 <code>userfields.qu_factor</code> 字段(默认 1)。修改去 Grocy 后台 → Master data → Products → Userfields。
|
||
</p>
|
||
</div>
|
||
|
||
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||
|
||
<div class="actions mt-6">
|
||
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '保存中…' : '保存' }}</button>
|
||
</div>
|
||
</form>
|
||
</AppLayout>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import AppLayout from '../components/AppLayout.vue';
|
||
import ChemPicker from '../components/ChemPicker.vue';
|
||
import * as washesApi from '../api/washes';
|
||
import * as vehiclesApi from '../api/vehicles';
|
||
import * as chemicalsApi from '../api/chemicals';
|
||
import { asArray } from '../api/client';
|
||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||
import AiFallbackModal from '../components/AiFallbackModal.vue';
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const vehicles = ref([]);
|
||
const chemicals = ref([]);
|
||
const error = ref('');
|
||
const busy = ref(false);
|
||
|
||
// AI 识别(洗车小票/订单截图)
|
||
const ai = useAiRecognize();
|
||
const aiBusy = ai.busy;
|
||
async function onAiRecognize() {
|
||
await ai.open('wash', (data) => {
|
||
if (data.wash_date) form.wash_date = data.wash_date;
|
||
if (data.wash_type) form.wash_type = data.wash_type;
|
||
if (data.cost != null) form.cost = data.cost;
|
||
if (data.location) form.location = data.location;
|
||
if (data.notes) form.notes = (form.notes ? form.notes + '\n' : '') + data.notes;
|
||
// 如果识别出 vehicle_hint,提示给用户(不自动选)
|
||
if (data.vehicle_hint) {
|
||
console.log('AI 识别到车辆线索:', data.vehicle_hint);
|
||
}
|
||
});
|
||
}
|
||
|
||
// AI 兜底 modal 提交:用户对着图手填后,确认。把 modal 关闭
|
||
// (form 字段已经双向绑定到 reactive,无需特殊处理)
|
||
function onManualConfirm() {
|
||
ai.cancelFallback();
|
||
}
|
||
const form = reactive({
|
||
wash_date: new Date().toISOString().slice(0, 10),
|
||
wash_type: 'full',
|
||
vehicle_id: '',
|
||
location: '',
|
||
cost: 0,
|
||
duration_min: 0,
|
||
notes: '',
|
||
});
|
||
const chemRows = ref([]);
|
||
|
||
// Grocy 的所有 quantity_units(用户能选的单位)
|
||
const availableUnits = ref([]);
|
||
|
||
const chemMap = computed(() => {
|
||
const m = {};
|
||
for (const c of chemicals.value) m[c.grocy_product_id] = c;
|
||
return m;
|
||
});
|
||
|
||
function stockUnit(row) {
|
||
const c = chemMap.value[row.chemical_id];
|
||
return c?.unit || '—';
|
||
}
|
||
|
||
function computedStockAmount(row) {
|
||
const c = chemMap.value[row.chemical_id];
|
||
if (!c) return '—';
|
||
const quFactor = Number(c.qu_factor || 1);
|
||
const v = Number(row.amount || 0) * quFactor;
|
||
// 3 位小数
|
||
return (Math.round(v * 1000) / 1000).toString();
|
||
}
|
||
|
||
function onChemChange(i, ch) {
|
||
// 选中产品后自动设为 stock unit
|
||
const row = chemRows.value[i];
|
||
if (ch?.unit) row.unit = ch.unit;
|
||
}
|
||
|
||
// 表单草稿:401 跳转登录前自动 flush,登录后回原页恢复
|
||
const draft = useFormDraft('washes/new');
|
||
const restored = draft.load();
|
||
if (restored) {
|
||
if (restored.form) Object.assign(form, restored.form);
|
||
if (Array.isArray(restored.chemRows) && restored.chemRows.length) chemRows.value = restored.chemRows;
|
||
}
|
||
watch([form, chemRows], () => {
|
||
draft.save({ form: { ...form }, chemRows: chemRows.value });
|
||
}, { deep: true });
|
||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||
onBeforeUnmount(() => unregisterFlush());
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const r = await vehiclesApi.list({ active: 1 });
|
||
vehicles.value = asArray(r.data, 'vehicles');
|
||
} catch {}
|
||
try {
|
||
const r = await chemicalsApi.all();
|
||
chemicals.value = asArray(r.data, 'chemicals');
|
||
} catch {}
|
||
// 拉 quantity_units 给单位下拉用
|
||
try {
|
||
const r = await fetch('/api/objects/quantity_units', { credentials: 'include' });
|
||
const j = await r.json();
|
||
if (Array.isArray(j)) availableUnits.value = j;
|
||
} catch {}
|
||
// PWA 快捷方式:?capture=1 → 自动唤起相机
|
||
if (route.query.capture === '1' || route.query.capture === 1) {
|
||
setTimeout(() => triggerCamera(), 400);
|
||
}
|
||
});
|
||
|
||
function triggerCamera() {
|
||
// 优先用 hidden 的 file input(无 camera 时回退为普通文件选择)
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/*';
|
||
input.setAttribute('capture', 'environment');
|
||
input.style.display = 'none';
|
||
input.onchange = async (e) => {
|
||
const f = e.target.files?.[0];
|
||
input.remove();
|
||
if (!f) return;
|
||
// 直接走 AI 识别流程(识别后会自动回填本表单)
|
||
try {
|
||
await ai.recognizeFromFile(f, 'wash');
|
||
} catch (err) {
|
||
// 失败时 fallback modal 会自动打开
|
||
}
|
||
};
|
||
document.body.appendChild(input);
|
||
input.click();
|
||
}
|
||
|
||
async function onSubmit() {
|
||
error.value = '';
|
||
busy.value = true;
|
||
try {
|
||
const payload = { ...form };
|
||
if (!payload.vehicle_id) delete payload.vehicle_id;
|
||
const chemicals_ = chemRows.value
|
||
.filter(c => c.chemical_id && c.amount > 0)
|
||
.map(c => ({ chemical_id: c.chemical_id, amount: c.amount, unit: c.unit }));
|
||
if (chemicals_.length) payload.chemicals = chemicals_;
|
||
const r = await washesApi.create(payload);
|
||
draft.clear();
|
||
router.push({ name: 'wash-show', params: { id: r.data?.id || r.data?.row?.id } });
|
||
} catch (e) {
|
||
error.value = e.response?.data?.message || e.response?.data?.code || '保存失败:' + e.message;
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||
.form { max-width: 760px; }
|
||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||
.chem-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.chem-row {
|
||
display: flex; gap: 8px; align-items: flex-start;
|
||
}
|
||
.chem-picker-col { flex: 1; min-width: 0; }
|
||
.chem-amount-col { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||
.chem-unit { width: 80px; font-size: 13px; }
|
||
.chem-amt { width: 90px; font-variant-numeric: tabular-nums; }
|
||
.chem-equiv { font-size: 12px; color: var(--text-soft); white-space: nowrap; line-height: 36px; flex-shrink: 0; }
|
||
.del-btn { flex-shrink: 0; line-height: 36px; }
|
||
.mt-2 { margin-top: 8px; }
|
||
.error {
|
||
color: var(--danger); background: #FBE3DF; padding: 8px 12px;
|
||
border-radius: var(--radius-sm); font-size: 13px;
|
||
}
|
||
.actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||
@media (max-width: 800px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||
|
||
@media (max-width: 767px) {
|
||
.title { font-size: 20px; }
|
||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||
.head .actions { width: 100%; }
|
||
.head .actions > * { flex: 1; justify-content: center; }
|
||
.grid { grid-template-columns: 1fr; }
|
||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||
}
|
||
</style>
|