mirror of
https://github.com/henry4682/fin_buddy_fe.git
synced 2026-07-16 01:30:00 +00:00
initial project
This commit is contained in:
566
src/Pages/Accounts/Show.vue
Normal file
566
src/Pages/Accounts/Show.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user