Files
fin_buddy_fe/src/Pages/Accounts/Show.vue
2026-06-25 22:39:06 +08:00

567 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<script setup>
import { ref, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import api from "@/axios.js";
import Modal from "../../Components/Modal.vue";
import InvestmentChart from "../../Components/InvestmentChart.vue";
import AccountTrendChart from "../../Components/AccountTrendChart.vue";
const route = useRoute();
const accountId = route.params.id;
const account = ref(null);
const holdings = ref([]);
const showHoldingModal = ref(false);
const expandedHoldingId = ref(null);
const transactions = ref({});
const currentPrices = ref({});
const showTransactionForm = ref(null);
const investmentHistory = ref({});
const securityOptions = ref([]);
const showOptions = ref(false);
let searchTimer = null;
const holdingForm = ref({
type: "stock",
symbol: "",
name: "",
});
const transactionForm = ref({
action: "buy",
shares: "",
price: "",
amount: "",
nav: "",
fee: "",
tax: "",
traded_at: "",
note: "",
});
const accountTrendData = ref([]);
const fetchAccountTrend = async () => {
try {
const res = await api.get(`/api/accounts/${accountId}/holdings-trend`);
accountTrendData.value = res.data;
} catch (e) {
console.error("無法撈取帳戶多線趨勢圖", e);
}
};
const fetchAccount = async () => {
const res = await api.get(`/api/accounts/${accountId}`);
account.value = res.data;
};
const fetchHoldings = async () => {
const res = await api.get(`/api/accounts/${accountId}/holdings`);
holdings.value = res.data;
// 🟢 修正:直接從大列表的內建數據裡,把最新價格和現值綁定給前端變數
res.data.forEach((holding) => {
currentPrices.value[holding.id] = Number(holding.latest_price);
});
};
const loadTransactions = async (holding) => {
const res = await api.get(`/api/holdings/${holding.id}/transactions`);
transactions.value[holding.id] = res.data;
};
const toggleHolding = async (holding) => {
if (expandedHoldingId.value === holding.id) {
expandedHoldingId.value = null;
return;
}
expandedHoldingId.value = holding.id;
await loadTransactions(holding);
if (holding.type === "stock" || holding.type === "fund") {
loadHistory(holding); // 非同步,不用 await
}
};
const loadHistory = async (holding) => {
try {
const res = await api.get(`/api/holdings/${holding.id}/history`);
investmentHistory.value[holding.id] = res.data;
} catch (e) {
console.error("取得歷史資料失敗", e);
}
};
const submitHolding = async () => {
await api.post(`/api/accounts/${accountId}/holdings`, holdingForm.value);
showHoldingModal.value = false;
holdingForm.value = { type: "stock", symbol: "", name: "" };
await fetchHoldings();
};
const deleteHolding = async (id) => {
if (!confirm("確定要刪除這筆持倉嗎?")) return;
await api.delete(`/api/holdings/delete/${accountId}/${id}`);
fetchHoldings();
};
const submitTransaction = async (holding) => {
await api.post(
`/api/holdings/${holding.id}/transactions`,
transactionForm.value,
);
transactionForm.value = {
action: "buy",
shares: "",
price: "",
amount: "",
nav: "",
fee: "",
tax: "",
traded_at: "",
note: "",
};
showTransactionForm.value = null; // 加這行
await fetchHoldings();
await loadTransactions(holding);
};
const deleteTransaction = async (holding, txId) => {
if (!confirm("確定要刪除這筆交易嗎?")) return;
await api.delete(`/api/holdings/${holding.id}/transactions/${txId}`);
await fetchHoldings();
await loadTransactions(holding);
};
const calcAmount = (tx, holding) => {
if (holding.type === "fund") {
if (tx.action === "buy") {
return Math.round(Number(tx.amount) + Number(tx.fee) + Number(tx.tax));
} else {
return Math.round(Number(tx.amount) - Number(tx.fee) - Number(tx.tax));
}
} else {
if (tx.action === "buy") {
return Math.floor(tx.shares * tx.price + Number(tx.fee) + Number(tx.tax));
} else {
return Math.floor(tx.shares * tx.price - Number(tx.fee) - Number(tx.tax));
}
}
};
const fetchPrice = async (holding) => {
try {
const res = await api.get(`/api/holdings/${holding.id}/realtime`);
currentPrices.value[holding.id] = res.data.price;
} catch (e) {
currentPrices.value[holding.id] = null;
}
};
const fetchAllPrices = async () => {
for (const holding of holdings.value) {
await fetchPrice(holding);
}
};
const currentValue = (holding) => {
const price = currentPrices.value[holding.id];
if (!price) return null;
return Math.round(holding.shares * price);
};
const profitLoss = (holding) => {
const value = currentValue(holding);
if (!value) return null;
const cost =
holding.type === "stock"
? Math.floor(holding.shares * holding.avg_cost)
: Math.round(holding.shares * holding.avg_cost);
return value - cost;
};
const searchSecurities = async (keyword) => {
if (!keyword) {
securityOptions.value = [];
return;
}
const { data } = await api.get("/api/securities/search", {
params: { type: holdingForm.value.type, keyword },
});
securityOptions.value = data;
showOptions.value = true;
};
watch(
() => holdingForm.value.symbol,
(val) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchSecurities(val), 300);
},
);
watch(
() => holdingForm.value.type,
() => {
// 切換類型時代號跟名稱可能對不上,清空避免誤送
holdingForm.value.symbol = "";
holdingForm.value.name = "";
securityOptions.value = [];
},
);
const selectSecurity = (item) => {
holdingForm.value.symbol = item.code;
holdingForm.value.name = item.name;
securityOptions.value = [];
showOptions.value = false;
};
const hideOptionsDelayed = () => {
// 延遲一下,讓點擊清單項目的 click 事件能先觸發
setTimeout(() => {
showOptions.value = false;
}, 150);
};
onMounted(async () => {
await fetchAccount();
await fetchHoldings();
await fetchAccountTrend();
});
</script>
<template>
<AuthenticatedLayout>
<template #header>{{ account?.name ?? "帳戶詳細" }}</template>
<div class="py-8 mx-auto max-w-4xl px-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold text-gray-700">持倉列表</h2>
<button
@click="showHoldingModal = true"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm"
>
+ 新增持倉
</button>
</div>
<div
v-if="accountTrendData.length"
class="bg-white rounded-lg shadow p-4 mb-6"
>
<h3 class="text-sm font-semibold text-gray-700 mb-3">
📈 各標的歷史報酬率變化趨勢 (%)
</h3>
<AccountTrendChart :trend-data="accountTrendData" />
</div>
<div
v-for="holding in holdings"
:key="holding.id"
class="bg-white rounded-lg shadow mb-4"
>
<!-- 持倉標題列點擊展開/收合 -->
<div
class="p-4 flex justify-between items-center cursor-pointer"
@click="toggleHolding(holding)"
>
<div class="flex flex-col items-start">
<div class="text-xs text-gray-400">
{{ holding.type === "stock" ? "股票" : "基金" }}
</div>
<div class="font-semibold text-gray-800">
{{ holding.symbol }} {{ holding.name }}
</div>
<div class="text-sm text-gray-500 mt-1">
持有 {{ holding.shares }} 單位 平均成本
{{ Number(holding.avg_cost).toLocaleString() }}
</div>
<div class="text-sm mt-1" v-if="currentPrices[holding.id]">
估計現值 {{ currentValue(holding)?.toLocaleString() }}
<span
:class="
profitLoss(holding) >= 0 ? 'text-green-600' : 'text-red-500'
"
>
{{ profitLoss(holding) >= 0 ? "+" : ""
}}{{ profitLoss(holding)?.toLocaleString() }}
</span>
報酬率:
<span
:class="
profitLoss(holding) >= 0 ? 'text-green-600' : 'text-red-500'
"
>
{{
(
(profitLoss(holding) /
(holding.shares * holding.avg_cost)) *
100
).toFixed(2)
}}%
</span>
</div>
<div v-else class="text-xs text-gray-400 mt-1">載入現值中...</div>
</div>
<div class="flex gap-3 items-center">
<button
@click.stop="deleteHolding(holding.id)"
class="text-xs text-red-500 hover:underline"
>
刪除
</button>
<span class="text-gray-400 text-sm">{{
expandedHoldingId === holding.id ? "▲" : "▼"
}}</span>
</div>
</div>
<!-- 展開的交易明細 -->
<div v-if="expandedHoldingId === holding.id" class="border-t px-4 pb-4">
<div class="flex justify-end my-3">
<button
@click="
showTransactionForm =
showTransactionForm === holding.id ? null : holding.id
"
class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
{{ showTransactionForm === holding.id ? "取消" : "+ 新增交易" }}
</button>
</div>
<!-- 新增交易表單 -->
<div
v-if="showTransactionForm === holding.id"
class="bg-gray-50 rounded-lg p-4 my-4"
>
<h4 class="text-sm font-semibold text-gray-600 mb-3">新增交易</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">買賣</label>
<select
v-model="transactionForm.action"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
>
<option value="buy">買入</option>
<option value="sell">賣出</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500">交易日期</label>
<input
v-model="transactionForm.traded_at"
type="date"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-if="holding.type === 'stock'">
<label class="text-xs text-gray-500">股數</label>
<input
v-model="transactionForm.shares"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-else>
<label class="text-xs text-gray-500">投入金額</label>
<input
v-model="transactionForm.amount"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-if="holding.type === 'stock'">
<label class="text-xs text-gray-500">成交價</label>
<input
v-model="transactionForm.price"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div v-else>
<label class="text-xs text-gray-500">淨值</label>
<input
v-model="transactionForm.nav"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">手續費</label>
<input
v-model="transactionForm.fee"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">交易稅</label>
<input
v-model="transactionForm.tax"
type="number"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
<div class="col-span-2">
<label class="text-xs text-gray-500">備註</label>
<input
v-model="transactionForm.note"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-1.5 text-sm"
/>
</div>
</div>
<div class="flex justify-end mt-3">
<button
@click="submitTransaction(holding)"
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
新增
</button>
</div>
</div>
<InvestmentChart
v-if="holding.type === 'stock' || holding.type === 'fund'"
:history="investmentHistory[holding.id]?.history"
:mark-points="investmentHistory[holding.id]?.buy_points"
:symbol="holding.symbol"
/>
<!-- 交易紀錄列表 -->
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-gray-400 border-b">
<th class="pb-2 text-left">日期</th>
<th class="pb-2 text-left">買賣</th>
<th class="pb-2 text-right">
{{ holding.type === "stock" ? "股數" : "金額" }}
</th>
<th class="pb-2 text-right">
{{ holding.type === "stock" ? "成交價" : "淨值" }}
</th>
<th class="pb-2 text-right">手續費</th>
<th class="pb-2 text-right"></th>
<th class="pb-2 text-right">買入/贖回金額</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in transactions[holding.id] || []"
:key="tx.id"
class="border-b last:border-0"
>
<td class="py-2 text-left">
{{ tx.traded_at.substring(0, 10) }}
</td>
<td class="py-2 text-left">
<span
:class="
tx.action === 'buy' ? 'text-green-600' : 'text-red-500'
"
>
{{ tx.action === "buy" ? "買入" : "賣出" }}
</span>
</td>
<td class="py-2 text-right">
{{
holding.type === "stock"
? tx.shares
: Number(tx.amount).toLocaleString()
}}
</td>
<td class="py-2 text-right">
{{
holding.type === "stock"
? Number(tx.price).toLocaleString()
: tx.nav
}}
</td>
<td class="py-2 text-right">{{ tx.fee }}</td>
<td class="py-2 text-right">{{ tx.tax }}</td>
<td class="py-2 text-right">
{{ calcAmount(tx, holding).toLocaleString() }}
</td>
<td class="py-2 text-right">
<button
@click="deleteTransaction(holding, tx.id)"
class="text-xs text-red-400 hover:underline"
>
刪除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div
v-if="showHoldingModal"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">新增持倉</h3>
<div class="space-y-3">
<div>
<label class="text-sm text-gray-600">類型</label>
<select
v-model="holdingForm.type"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
>
<option value="stock">股票</option>
<option value="fund">基金</option>
</select>
</div>
<div class="relative">
<label class="text-sm text-gray-600">代號</label>
<input
v-model="holdingForm.symbol"
type="text"
autocomplete="off"
@focus="showOptions = securityOptions.length > 0"
@blur="hideOptionsDelayed"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
<ul
v-if="showOptions && securityOptions.length"
class="absolute z-10 bg-white border rounded-md mt-1 w-full max-h-48 overflow-auto shadow-lg"
>
<li
v-for="item in securityOptions"
:key="item.code"
@click="selectSecurity(item)"
class="px-3 py-2 text-sm hover:bg-gray-100 cursor-pointer"
>
<span class="font-medium">{{ item.code }}</span>
<span class="text-gray-500 ml-2">{{ item.name }}</span>
</li>
</ul>
</div>
<div>
<label class="text-sm text-gray-600">名稱</label>
<input
v-model="holdingForm.name"
type="text"
class="mt-1 block w-full border rounded-md px-3 py-2 text-sm"
/>
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button
@click="showHoldingModal = false"
class="px-4 py-2 text-sm text-gray-600 hover:underline"
>
取消
</button>
<button
@click="submitHolding"
class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700"
>
儲存
</button>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>