mirror of
https://github.com/henry4682/fin_buddy_fe.git
synced 2026-07-16 01:30:00 +00:00
567 lines
18 KiB
Vue
567 lines
18 KiB
Vue
<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>
|