Files
CarLog/client/src/views/WashNew.vue
T
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
2026-06-20 21:11:54 +08:00

327 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>