initial project

This commit is contained in:
2026-06-25 22:39:06 +08:00
commit 5a5ac2fcde
110 changed files with 8693 additions and 0 deletions

566
src/Pages/Accounts/Show.vue Normal file
View 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>