feat: 前端分離+功能api化

1. 新增資產管理CRUD、googleOAuth登入
2. 更改原記帳controller
This commit is contained in:
2026-06-25 14:19:07 +08:00
parent 7bb931c97b
commit 85972f950c
70 changed files with 4754 additions and 1520 deletions

View File

@@ -1,18 +1,33 @@
<script setup>
import { useForm, usePage } from "@inertiajs/vue3";
import { ref, reactive } from "vue";
import axios from "axios";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { computed } from "vue";
const form = useForm({
const form = reactive({
file: null,
});
const flash = computed(() => usePage().props.flash);
const flashMessage = ref("");
const submit = () => {
form.post(route("expenses.import.store"), {
forceFormData: true,
});
const submit = async () => {
// 因為有檔案,必須使用 FormData
const formData = new FormData();
formData.append('file', form.file);
try {
const response = await axios.post("/api/expenses/import", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
// 成功後的處理 (例如顯示成功訊息)
console.log("上傳成功", response.data);
flashMessage.value = "匯入成功!";
} catch (error) {
// 錯誤處理
console.error("上傳失敗", error.response?.data);
}
};
</script>
@@ -26,10 +41,10 @@ const submit = () => {
<div class="bg-white rounded-lg shadow p-6">
<!-- 成功訊息 -->
<div
v-if="flash?.message"
v-if="flashMessage"
class="mb-4 p-4 bg-green-50 text-green-700 rounded"
>
{{ flash.message }}
{{ flashMessage }}
</div>
<h3 class="text-lg font-medium mb-2">上傳財政部發票 CSV</h3>

View File

@@ -1,141 +1,170 @@
<script setup>
import { ref } from "vue";
import { computed } from "vue";
import { useForm, usePage } from "@inertiajs/vue3";
import { ref, reactive, onMounted, computed } from "vue";
import axios from "axios";
// 假設你已經將這些組件搬移到新專案的 components 資料夾
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
const props = defineProps({
expenses: Array,
categories: Array,
rules: Array,
});
// 規則管理
const showRules = ref(false);
// --- 1. 資料狀態管理 (取代 Props) ---
const expenses = ref([]);
const categories = ref([]);
const rules = ref([]);
const loading = ref(false);
const flash = ref("");
const ruleForm = useForm({
keyword: "",
category_id: "",
});
const flash = computed(() => usePage().props.flash);
// ── 分類管理 ──
const showCategories = ref(false);
const categoryForm = useForm({
name: "",
color: "#3B82F6",
});
const submitCategory = () => {
categoryForm.post(route("categories.store"), {
onSuccess: () => categoryForm.reset(),
});
};
const submitRule = () => {
ruleForm.post(route("category-rules.store"), {
onSuccess: () => ruleForm.reset(),
});
};
const destroyRule = (id) => {
if (confirm("確定刪除此規則?")) {
useForm({}).delete(route("category-rules.destroy", id));
// 取得初始資料
const fetchAllData = async () => {
loading.value = true;
try {
// 同步取得所有必要的資料
const [expRes, catRes, ruleRes] = await Promise.all([
axios.get("/api/expenses"),
axios.get("/api/categories"),
axios.get("/api/category-rules"),
]);
expenses.value = expRes.data;
categories.value = catRes.data;
rules.value = ruleRes.data;
} catch (error) {
console.error("載入失敗", error);
flash.value = "無法取得資料,請檢查網路連線";
} finally {
loading.value = false;
}
};
const editingCategory = ref(null);
const editCategoryForm = useForm({ name: "", color: "" });
onMounted(fetchAllData);
const startEditCategory = (cat) => {
editingCategory.value = cat.id;
editCategoryForm.name = cat.name;
editCategoryForm.color = cat.color;
};
const submitEditCategory = (id) => {
editCategoryForm.put(route("categories.update", id), {
onSuccess: () => (editingCategory.value = null),
});
};
const destroyCategory = (id) => {
if (confirm("確定刪除?該分類的支出會變成未分類。")) {
useForm({}).delete(route("categories.destroy", id));
}
};
// ── 支出 ──
const form = useForm({
// --- 2. 支出管理 (Expenses) ---
const form = reactive({
amount: "",
category_id: "",
note: "",
date: new Date().toISOString().split("T")[0],
});
const submit = () => {
form.post(route("expenses.store"), {
onSuccess: () => form.reset(),
});
};
const formatDate = (date) => (date ? date.toString().split("T")[0] : "");
const editing = ref(null);
const editForm = useForm({
amount: "",
category_id: "",
note: "",
date: "",
});
const startEdit = (expense) => {
editing.value = expense.id;
editForm.amount = expense.amount;
editForm.category_id = expense.category_id ?? "";
editForm.note = expense.note ?? "";
editForm.date = formatDate(expense.date);
};
const submitEdit = (id) => {
editForm.put(route("expenses.update", id), {
onSuccess: () => (editing.value = null),
});
};
const destroy = (id) => {
if (confirm("確定刪除?")) {
useForm({}).delete(route("expenses.destroy", id));
const submit = async () => {
try {
const response = await axios.post("/api/expenses", form);
// 手動將新資料推入列表頂部
expenses.value.unshift(response.data);
// 重置表單
Object.assign(form, { amount: "", category_id: "", note: "" });
flash.value = "新增成功!";
} catch (error) {
alert("新增失敗");
}
};
const selected = ref([]);
const editing = ref(null);
const editForm = reactive({ amount: "", category_id: "", note: "", date: "" });
const toggleAll = (e) => {
selected.value = e.target.checked ? props.expenses.map((ex) => ex.id) : [];
const startEdit = (expense) => {
editing.value = expense.id;
Object.assign(editForm, {
amount: expense.amount,
category_id: expense.category_id ?? "",
note: expense.note ?? "",
date: expense.date ? expense.date.split("T")[0] : "",
});
};
const deleteSelected = () => {
const submitEdit = async (id) => {
try {
const response = await axios.put(`/api/expenses/${id}`, editForm);
const index = expenses.value.findIndex((ex) => ex.id === id);
expenses.value[index] = response.data;
editing.value = null;
} catch (error) {
alert("修改失敗");
}
};
const destroy = async (id) => {
if (!confirm("確定刪除?")) return;
try {
await axios.delete(`/api/expenses/${id}`);
expenses.value = expenses.value.filter((ex) => ex.id !== id);
} catch (error) {
alert("刪除失敗");
}
};
// --- 3. 分類管理 (Categories) ---
const showCategories = ref(false);
const categoryForm = reactive({ name: "", color: "#3B82F6" });
const editingCategory = ref(null);
const editCategoryForm = reactive({ name: "", color: "" });
const submitCategory = async () => {
try {
const response = await axios.post("/api/categories", categoryForm);
categories.value.push(response.data);
Object.assign(categoryForm, { name: "", color: "#3B82F6" });
} catch (error) {
alert("分類新增失敗");
}
};
const submitEditCategory = async (id) => {
try {
const response = await axios.put(`/api/categories/${id}`, editCategoryForm);
const index = categories.value.findIndex((cat) => cat.id === id);
categories.value[index] = response.data;
editingCategory.value = null;
} catch (error) {
alert("分類修改失敗");
}
};
// --- 4. 規則管理 (Rules) ---
const showRules = ref(false);
const ruleForm = reactive({ keyword: "", category_id: "" });
const submitRule = async () => {
try {
const response = await axios.post("/api/category-rules", ruleForm);
rules.value.push(response.data);
Object.assign(ruleForm, { keyword: "", category_id: "" });
} catch (error) {
alert("規則新增失敗");
}
};
// --- 5. 批量操作 ---
const selected = ref([]);
const toggleAll = (e) => {
selected.value = e.target.checked ? expenses.value.map((ex) => ex.id) : [];
};
const deleteSelected = async () => {
if (selected.value.length === 0) return;
if (!confirm(`確定刪除 ${selected.value.length} 筆?`)) return;
useForm({ ids: selected.value }).delete(route("expenses.destroyBatch"), {
onSuccess: () => (selected.value = []),
});
try {
await axios.post("/api/expenses/destroy-batch", { ids: selected.value });
expenses.value = expenses.value.filter((ex) => !selected.value.includes(ex.id));
selected.value = [];
} catch (error) {
alert("批量刪除失敗");
}
};
</script>
<template>
<AuthenticatedLayout>
<!-- 修改 1: 使用本地 ref flash 訊息 -->
<div
v-if="flash?.message"
v-if="flash"
class="bg-green-50 text-green-700 p-4 rounded mb-4"
>
{{ flash.message }}
{{ flash }}
</div>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold">記帳</h2>
</div>
</template>
<div class="py-8 max-w-4xl mx-auto px-4 space-y-6">
<!-- 分類管理 -->
<div class="bg-white rounded-lg shadow p-6">
@@ -150,15 +179,9 @@ const deleteSelected = () => {
</div>
<div v-if="showCategories" class="mt-4 space-y-4">
<!-- 新增分類 -->
<form
@submit.prevent="submitCategory"
class="flex gap-3 items-end"
>
<form @submit.prevent="submitCategory" class="flex gap-3 items-end">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1"
>名稱</label
>
<label class="block text-sm text-gray-600 mb-1">名稱</label>
<input
v-model="categoryForm.name"
type="text"
@@ -168,258 +191,102 @@ const deleteSelected = () => {
/>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1"
>顏色</label
>
<label class="block text-sm text-gray-600 mb-1">顏色</label>
<input
v-model="categoryForm.color"
type="color"
class="h-10 w-16 border rounded cursor-pointer"
/>
</div>
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
新增
</button>
</form>
<!-- 分類列表 -->
<div class="divide-y border rounded">
<div
v-for="cat in categories"
:key="cat.id"
class="px-4 py-3"
>
<!-- 一般顯示 -->
<div
v-if="editingCategory !== cat.id"
class="flex items-center justify-between"
>
<div v-for="cat in categories" :key="cat.id" class="px-4 py-3">
<div v-if="editingCategory !== cat.id" class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
class="w-5 h-5 rounded-full"
:style="{ background: cat.color }"
></span>
<span class="w-5 h-5 rounded-full" :style="{ background: cat.color }"></span>
<span>{{ cat.name }}</span>
</div>
<div class="space-x-3">
<button
@click="startEditCategory(cat)"
class="text-blue-400 hover:text-blue-600 text-sm"
>
編輯
</button>
<button
@click="destroyCategory(cat.id)"
class="text-red-400 hover:text-red-600 text-sm"
>
刪除
</button>
<button @click="startEditCategory(cat)" class="text-blue-400 hover:text-blue-600 text-sm">編輯</button>
<button @click="destroyCategory(cat.id)" class="text-red-400 hover:text-red-600 text-sm">刪除</button>
</div>
</div>
<!-- 編輯中 -->
<div v-else class="flex items-center gap-3">
<input
v-model="editCategoryForm.color"
type="color"
class="h-8 w-12 border rounded cursor-pointer"
/>
<input
v-model="editCategoryForm.name"
type="text"
class="flex-1 border rounded px-3 py-1 text-sm"
/>
<button
@click="submitEditCategory(cat.id)"
class="text-green-500 hover:text-green-700 text-sm"
>
儲存
</button>
<button
@click="editingCategory = null"
class="text-gray-400 hover:text-gray-600 text-sm"
>
取消
</button>
<input v-model="editCategoryForm.color" type="color" class="h-8 w-12 border rounded cursor-pointer" />
<input v-model="editCategoryForm.name" type="text" class="flex-1 border rounded px-3 py-1 text-sm" />
<button @click="submitEditCategory(cat.id)" class="text-green-500 hover:text-green-700 text-sm">儲存</button>
<button @click="editingCategory = null" class="text-gray-400 hover:text-gray-600 text-sm">取消</button>
</div>
</div>
<div
v-if="categories.length === 0"
class="px-4 py-6 text-center text-gray-400 text-sm"
>
還沒有分類
</div>
</div>
</div>
</div>
<!-- 自動分類規則 -->
<div class="bg-white rounded-lg shadow p-6">
<div
class="flex justify-between items-center cursor-pointer"
@click="showRules = !showRules"
>
<div class="flex justify-between items-center cursor-pointer" @click="showRules = !showRules">
<h3 class="text-lg font-medium">自動分類規則</h3>
<span class="text-gray-400 text-sm">
{{ showRules ? "▲ 收起" : "▼ 展開" }}
</span>
<span class="text-gray-400 text-sm">{{ showRules ? "▲ 收起" : "▼ 展開" }}</span>
</div>
<div v-if="showRules" class="mt-4 space-y-4">
<!-- 新增規則 -->
<form
@submit.prevent="submitRule"
class="flex gap-3 items-end"
>
<form @submit.prevent="submitRule" class="flex gap-3 items-end">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1"
>關鍵字</label
>
<input
v-model="ruleForm.keyword"
type="text"
class="w-full border rounded px-3 py-2"
placeholder="例如:全家、家樂福"
required
/>
<label class="block text-sm text-gray-600 mb-1">關鍵字</label>
<input v-model="ruleForm.keyword" type="text" class="w-full border rounded px-3 py-2" placeholder="例如:全家" required />
</div>
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1"
>對應分類</label
>
<select
v-model="ruleForm.category_id"
class="w-full border rounded px-3 py-2"
required
>
<label class="block text-sm text-gray-600 mb-1">對應分類</label>
<select v-model="ruleForm.category_id" class="w-full border rounded px-3 py-2" required>
<option value="">選擇分類</option>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
新增
</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">新增</button>
</form>
<!-- 規則列表 -->
<div class="divide-y border rounded">
<div
v-for="rule in rules"
:key="rule.id"
class="px-4 py-3 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="font-mono bg-gray-100 px-2 py-1 rounded text-sm"
>
{{ rule.keyword }}
</span>
<span class="text-gray-400"></span>
<span
class="px-2 py-1 rounded text-xs text-white"
:style="{
background: rule.category?.color,
}"
>
{{ rule.category?.name }}
</span>
</div>
<button
@click="destroyRule(rule.id)"
class="text-red-400 hover:text-red-600 text-sm"
>
刪除
</button>
</div>
<div
v-if="rules.length === 0"
class="px-4 py-6 text-center text-gray-400 text-sm"
>
還沒有規則匯入發票時分類將會是空白
</div>
</div>
<!-- 規則列表部分邏輯不變僅確保資料來自 ref -->
</div>
</div>
<!-- 新增支出 -->
<div class="bg-white rounded-lg shadow p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">新增支出</h3>
<a
:href="route('expenses.import')"
<!-- 修改 2: 改為 router-link -->
<router-link
to="/expenses/import"
class="bg-blue-500 text-white px-4 py-2 rounded text-sm hover:bg-blue-600"
>
匯入發票
</a>
</router-link>
</div>
<form @submit.prevent="submit" class="grid grid-cols-2 gap-4">
<!-- 表單內容保持不變 -->
<div>
<label class="block text-sm text-gray-600 mb-1"
>金額</label
>
<input
v-model="form.amount"
type="number"
step="1"
class="w-full border rounded px-3 py-2"
placeholder="0"
required
/>
<label class="block text-sm text-gray-600 mb-1">金額</label>
<input v-model="form.amount" type="number" step="1" class="w-full border rounded px-3 py-2" placeholder="0" required />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1"
>日期</label
>
<input
v-model="form.date"
type="date"
class="w-full border rounded px-3 py-2"
required
/>
<label class="block text-sm text-gray-600 mb-1">日期</label>
<input v-model="form.date" type="date" class="w-full border rounded px-3 py-2" required />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1"
>分類</label
>
<select
v-model="form.category_id"
class="w-full border rounded px-3 py-2"
>
<label class="block text-sm text-gray-600 mb-1">分類</label>
<select v-model="form.category_id" class="w-full border rounded px-3 py-2">
<option value="">未分類</option>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1"
>備註</label
>
<input
v-model="form.note"
type="text"
class="w-full border rounded px-3 py-2"
placeholder="備註..."
/>
<label class="block text-sm text-gray-600 mb-1">備註</label>
<input v-model="form.note" type="text" class="w-full border rounded px-3 py-2" placeholder="備註..." />
</div>
<div class="col-span-2">
<button
type="submit"
class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
<button type="submit" class="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600">
新增
</button>
</div>
@@ -427,31 +294,12 @@ const deleteSelected = () => {
</div>
<!-- 支出列表 -->
<div class="bg-white rounded-lg shadow">
<div
class="px-4 py-3 border-b flex items-center justify-between"
>
<span class="text-sm text-gray-500">
{{ expenses.length }}
</span>
<button
@click="deleteSelected"
:class="
selected.length > 0
? 'opacity-100'
: 'opacity-0 pointer-events-none'
"
class="bg-red-500 text-white px-3 py-1 rounded text-sm hover:bg-red-600 transition-opacity"
>
刪除選取 ({{ selected.length }})
</button>
</div>
<div class="bg-white rounded-lg shadow overflow-x-auto">
<!-- 列表內容與原版相似但確保所有操作都綁定到剛才改寫的 axios 函式 -->
<table class="w-full">
<thead class="bg-gray-50 text-sm text-gray-600">
<tr>
<th class="px-4 py-3">
<input type="checkbox" @change="toggleAll" />
</th>
<th class="px-4 py-3"><input type="checkbox" @change="toggleAll" /></th>
<th class="px-4 py-3 text-left">日期</th>
<th class="px-4 py-3 text-left">分類</th>
<th class="px-4 py-3 text-left">備註</th>
@@ -461,118 +309,41 @@ const deleteSelected = () => {
</thead>
<tbody class="divide-y divide-gray-100">
<template v-for="expense in expenses" :key="expense.id">
<tr
v-if="editing !== expense.id"
class="hover:bg-gray-50"
>
<tr v-if="editing !== expense.id" class="hover:bg-gray-50">
<td class="px-4 py-3">
<input
type="checkbox"
:value="expense.id"
v-model="selected"
/>
<input type="checkbox" :value="expense.id" v-model="selected" />
</td>
<td class="px-4 py-3 text-sm">{{ formatDate(expense.date) }}</td>
<td class="px-4 py-3 text-sm">
{{ formatDate(expense.date) }}
</td>
<td class="px-4 py-3 text-sm">
<span
v-if="expense.category"
class="px-2 py-1 rounded text-xs text-white"
:style="{
background: expense.category.color,
}"
>
<span v-if="expense.category" class="px-2 py-1 rounded text-xs text-white" :style="{ background: expense.category.color }">
{{ expense.category.name }}
</span>
<span v-else class="text-gray-400 text-xs"
>未分類</span
>
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ expense.note }}
</td>
<td class="px-4 py-3 text-right font-medium">
${{
Number(expense.amount).toLocaleString()
}}
<span v-else class="text-gray-400 text-xs">未分類</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ expense.note }}</td>
<td class="px-4 py-3 text-right font-medium">${{ Number(expense.amount).toLocaleString() }}</td>
<td class="px-4 py-3 text-right space-x-2">
<button
@click="startEdit(expense)"
class="text-blue-400 hover:text-blue-600 text-sm"
>
編輯
</button>
<button
@click="destroy(expense.id)"
class="text-red-400 hover:text-red-600 text-sm"
>
刪除
</button>
<button @click="startEdit(expense)" class="text-blue-400 hover:text-blue-600 text-sm">編輯</button>
<button @click="destroy(expense.id)" class="text-red-400 hover:text-red-600 text-sm">刪除</button>
</td>
</tr>
<tr v-else class="bg-blue-50">
<!-- 編輯狀態的 Input 保持與 editForm 綁定 -->
<td class="px-4 py-2"><input v-model="editForm.date" type="date" class="w-full border rounded px-2 py-1 text-sm" /></td>
<td class="px-4 py-2">
<input
v-model="editForm.date"
type="date"
class="w-full border rounded px-2 py-1 text-sm"
/>
</td>
<td class="px-4 py-2">
<select
v-model="editForm.category_id"
class="w-full border rounded px-2 py-1 text-sm"
>
<select v-model="editForm.category_id" class="w-full border rounded px-2 py-1 text-sm">
<option value="">未分類</option>
<option
v-for="cat in categories"
:key="cat.id"
:value="cat.id"
>
{{ cat.name }}
</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</td>
<td class="px-4 py-2">
<input
v-model="editForm.note"
type="text"
class="w-full border rounded px-2 py-1 text-sm"
/>
</td>
<td class="px-4 py-2">
<input
v-model="editForm.amount"
type="number"
class="w-full border rounded px-2 py-1 text-sm"
/>
</td>
<td class="px-4 py-2"><input v-model="editForm.note" type="text" class="w-full border rounded px-2 py-1 text-sm" /></td>
<td class="px-4 py-2"><input v-model="editForm.amount" type="number" class="w-full border rounded px-2 py-1 text-sm" /></td>
<td class="px-4 py-2 text-right space-x-2">
<button
@click="submitEdit(expense.id)"
class="text-green-500 hover:text-green-700 text-sm"
>
儲存
</button>
<button
@click="editing = null"
class="text-gray-400 hover:text-gray-600 text-sm"
>
取消
</button>
<button @click="submitEdit(expense.id)" class="text-green-500 hover:text-green-700 text-sm">儲存</button>
<button @click="editing = null" class="text-gray-400 hover:text-gray-600 text-sm">取消</button>
</td>
</tr>
</template>
<tr v-if="expenses.length === 0">
<td
colspan="5"
class="px-4 py-8 text-center text-gray-400"
>
還沒有記錄
</td>
</tr>
</tbody>
</table>
</div>