Compare commits
4 Commits
b9186d4699
...
ba6a922f2c
| Author | SHA1 | Date | |
|---|---|---|---|
| ba6a922f2c | |||
| effa84fe14 | |||
| 67ceb63b08 | |||
| 746be06840 |
@ -1,6 +1,6 @@
|
||||
import { getAssets } from '@/actions/asset';
|
||||
import { AddAssetDialog } from '@/components/assets/add-asset-dialog';
|
||||
import { UpdatePriceDialog } from '@/components/assets/update-price-dialog';
|
||||
import { SyncButton } from '@/components/assets/sync-button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -10,6 +10,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import Big from 'big.js';
|
||||
|
||||
export default async function AssetsPage() {
|
||||
const assets = await getAssets();
|
||||
@ -24,8 +25,11 @@ export default async function AssetsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">資產列表</h1>
|
||||
<div className="flex gap-2">
|
||||
<SyncButton />
|
||||
<AddAssetDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
@ -37,13 +41,12 @@ export default async function AssetsPage() {
|
||||
<TableHead>基礎幣種</TableHead>
|
||||
<TableHead>當前市價 (Latest Price)</TableHead>
|
||||
<TableHead>創建時間</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
暫無資產,點擊"添加資產"按鈕錄入第一個資產
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -53,15 +56,12 @@ export default async function AssetsPage() {
|
||||
<TableCell className="font-medium">{asset.symbol}</TableCell>
|
||||
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
|
||||
<TableCell>{asset.baseCurrency}</TableCell>
|
||||
<TableCell>{asset.latestPrice}</TableCell>
|
||||
<TableCell>{asset.latestPrice ? new Big(asset.latestPrice).toString() : '-'}</TableCell>
|
||||
<TableCell>
|
||||
{asset.createdAt
|
||||
? new Date(asset.createdAt).toLocaleString('zh-CN')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice || '0'} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -90,21 +90,33 @@ export default async function DashboardPage() {
|
||||
<span className="font-medium">{pos.baseCurrency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">CNY 估值</span>
|
||||
<span className="font-medium text-green-600">
|
||||
¥{formatAmount(pos.cnyValue)}
|
||||
<span className="text-muted-foreground">持仓成本 ({pos.baseCurrency})</span>
|
||||
<span className="font-medium">
|
||||
{formatAmount(pos.totalCostNative)}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const posPnlNative = new Big(pos.pnlNative);
|
||||
const posPnlNativePositive = posPnlNative.gte(0);
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">当前盈亏 ({pos.baseCurrency})</span>
|
||||
<span className={`font-semibold ${posPnlNativePositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex justify-between opacity-50">
|
||||
<span className="text-muted-foreground">成本 (CNY)</span>
|
||||
<span className="font-medium">
|
||||
¥{formatAmount(pos.totalCostCny)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">盈亏</span>
|
||||
<div className="flex justify-between opacity-50">
|
||||
<span className="text-muted-foreground">盈亏 (CNY)</span>
|
||||
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{posPnlPositive ? '+' : ''}{formattedPosPnl}
|
||||
{posPnlPositive ? '+' : ''}{formatAmount(pos.pnlCny)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
284
package-lock.json
generated
284
package-lock.json
generated
@ -29,6 +29,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"yahoo-finance2": "^3.14.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -299,6 +300,46 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deno/shim-deno": {
|
||||
"version": "0.18.2",
|
||||
"resolved": "https://registry.npmmirror.com/@deno/shim-deno/-/shim-deno-0.18.2.tgz",
|
||||
"integrity": "sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deno/shim-deno-test": "^0.5.0",
|
||||
"which": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deno/shim-deno-test": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@deno/shim-deno-test/-/shim-deno-test-0.5.0.tgz",
|
||||
"integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@deno/shim-deno/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz",
|
||||
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@deno/shim-deno/node_modules/which": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/which/-/which-4.0.0.tgz",
|
||||
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmmirror.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
@ -4547,7 +4588,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -5556,6 +5596,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock-cache": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz",
|
||||
"integrity": "sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"filenamify-url": "2.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@ -5569,6 +5619,48 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filename-reserved-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
|
||||
"integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/filenamify": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/filenamify/-/filenamify-4.3.0.tgz",
|
||||
"integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"filename-reserved-regex": "^2.0.0",
|
||||
"strip-outer": "^1.0.1",
|
||||
"trim-repeated": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/filenamify-url": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/filenamify-url/-/filenamify-url-2.1.2.tgz",
|
||||
"integrity": "sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"filenamify": "^4.3.0",
|
||||
"humanize-url": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -5967,6 +6059,18 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-url": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/humanize-url/-/humanize-url-2.1.1.tgz",
|
||||
"integrity": "sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-url": "^4.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -6542,6 +6646,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@ -6761,7 +6871,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@ -6942,6 +7051,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-4.5.1.tgz",
|
||||
"integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -7396,16 +7514,33 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@ -7671,6 +7806,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz",
|
||||
@ -8218,6 +8359,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-outer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-outer/-/strip-outer-1.0.1.tgz",
|
||||
"integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-outer/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@ -8500,6 +8662,24 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@ -8512,6 +8692,66 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie-file-store": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/tough-cookie-file-store/-/tough-cookie-file-store-2.0.3.tgz",
|
||||
"integrity": "sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tough-cookie": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie-file-store/node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-repeated": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/trim-repeated/-/trim-repeated-1.0.0.tgz",
|
||||
"integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-repeated/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@ -9222,6 +9462,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
@ -9298,6 +9547,16 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
@ -9493,6 +9752,25 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yahoo-finance2": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/yahoo-finance2/-/yahoo-finance2-3.14.0.tgz",
|
||||
"integrity": "sha512-gsT/tqgeizKtMxbIIWFiFyuhM/6MZE4yEyNLmPekr88AX14JL2HWw0/QNMOR081jVtzTjihqDW0zV7IayH1Wcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deno/shim-deno": "~0.18.0",
|
||||
"fetch-mock-cache": "npm:fetch-mock-cache@^2.1.3",
|
||||
"json-schema": "^0.4.0",
|
||||
"tough-cookie": "npm:tough-cookie@^5.1.1",
|
||||
"tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"yahoo-finance": "esm/bin/yahoo-finance.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"yahoo-finance2": "^3.14.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
36
src/actions/market.ts
Normal file
36
src/actions/market.ts
Normal file
@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import yahooFinance from 'yahoo-finance2';
|
||||
import { db } from '@/db';
|
||||
import { assets } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function syncAllStockPrices() {
|
||||
const stockAssets = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.type, 'STOCK'));
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const asset of stockAssets) {
|
||||
try {
|
||||
const quote = await yahooFinance.quote(asset.symbol);
|
||||
if (quote && quote.regularMarketPrice !== undefined) {
|
||||
await db
|
||||
.update(assets)
|
||||
.set({ latestPrice: quote.regularMarketPrice.toString() })
|
||||
.where(eq(assets.id, asset.id));
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch price for ${asset.symbol}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard');
|
||||
revalidatePath('/dashboard/assets');
|
||||
|
||||
return { success: true, count: successCount };
|
||||
}
|
||||
@ -14,6 +14,8 @@ interface Position {
|
||||
cnyValue: string;
|
||||
totalCostCny: string;
|
||||
pnlCny: string;
|
||||
totalCostNative: string;
|
||||
pnlNative: string;
|
||||
}
|
||||
|
||||
interface RawRate {
|
||||
@ -54,7 +56,7 @@ function calculateCnyValueFromPrice(
|
||||
|
||||
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
||||
if (directRate) {
|
||||
return quantity.times(price).times(directRate);
|
||||
return quantity.times(price).times(new Big(directRate));
|
||||
}
|
||||
|
||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
||||
@ -64,7 +66,7 @@ function calculateCnyValueFromPrice(
|
||||
|
||||
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
||||
if (usdRate) {
|
||||
return quantity.times(price).times(usdRate).times(usdToCny);
|
||||
return quantity.times(price).times(new Big(usdRate)).times(new Big(usdToCny));
|
||||
}
|
||||
|
||||
return new Big('0');
|
||||
@ -77,6 +79,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
quantity: transactions.quantity,
|
||||
price: transactions.price,
|
||||
exchangeRate: transactions.exchangeRate,
|
||||
txCurrency: transactions.txCurrency,
|
||||
assetId: transactions.assetId,
|
||||
assetSymbol: assets.symbol,
|
||||
assetType: assets.type,
|
||||
@ -87,6 +90,14 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
||||
.orderBy(desc(transactions.executedAt));
|
||||
|
||||
const rates = await db.select({
|
||||
fromCurrency: exchangeRates.fromCurrency,
|
||||
toCurrency: exchangeRates.toCurrency,
|
||||
rate: exchangeRates.rate,
|
||||
}).from(exchangeRates);
|
||||
|
||||
const rateMap = buildRateMap(rates);
|
||||
|
||||
const holdings = new Map<string, {
|
||||
assetId: string;
|
||||
symbol: string;
|
||||
@ -95,6 +106,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
baseCurrency: string;
|
||||
latestPrice: string;
|
||||
totalCostCny: Big;
|
||||
totalCostNative: Big;
|
||||
}>();
|
||||
|
||||
for (const tx of allTransactions) {
|
||||
@ -110,22 +122,42 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
baseCurrency: tx.assetBaseCurrency || '',
|
||||
latestPrice: tx.assetLatestPrice || '0',
|
||||
totalCostCny: new Big('0'),
|
||||
totalCostNative: new Big('0'),
|
||||
});
|
||||
}
|
||||
|
||||
const holding = holdings.get(tx.assetId)!;
|
||||
|
||||
if (tx.txType === 'BUY') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
const costPerUnit = tx.quantity.times(tx.price);
|
||||
const costCny = costPerUnit.times(tx.exchangeRate || '1');
|
||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||
const costPerUnit = new Big(tx.quantity).times(new Big(tx.price));
|
||||
holding.totalCostNative = holding.totalCostNative.plus(costPerUnit);
|
||||
let appliedRate = tx.exchangeRate;
|
||||
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
||||
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
||||
if (fallbackRate) {
|
||||
appliedRate = fallbackRate;
|
||||
}
|
||||
}
|
||||
const costCny = costPerUnit.times(new Big(appliedRate || '1'));
|
||||
holding.totalCostCny = holding.totalCostCny.plus(costCny);
|
||||
} else if (tx.txType === 'SELL') {
|
||||
holding.quantity = holding.quantity.minus(tx.quantity);
|
||||
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
|
||||
const sellCostPerUnit = new Big(tx.quantity).times(new Big(tx.price));
|
||||
holding.totalCostNative = holding.totalCostNative.minus(sellCostPerUnit);
|
||||
let appliedRate = tx.exchangeRate;
|
||||
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
||||
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
||||
if (fallbackRate) {
|
||||
appliedRate = fallbackRate;
|
||||
}
|
||||
}
|
||||
const sellCostCny = sellCostPerUnit.times(new Big(appliedRate || '1'));
|
||||
holding.totalCostCny = holding.totalCostCny.minus(sellCostCny);
|
||||
} else if (tx.txType === 'AIRDROP') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||
} else if (tx.txType === 'DIVIDEND') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||
}
|
||||
|
||||
if (tx.assetLatestPrice) {
|
||||
@ -133,14 +165,6 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
}
|
||||
}
|
||||
|
||||
const rates = await db.select({
|
||||
fromCurrency: exchangeRates.fromCurrency,
|
||||
toCurrency: exchangeRates.toCurrency,
|
||||
rate: exchangeRates.rate,
|
||||
}).from(exchangeRates);
|
||||
|
||||
const rateMap = buildRateMap(rates);
|
||||
|
||||
const result: Position[] = [];
|
||||
let totalCnyValue = new Big('0');
|
||||
let totalPnlCny = new Big('0');
|
||||
@ -160,6 +184,9 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
const pnlCny = cnyValue.minus(holding.totalCostCny);
|
||||
totalPnlCny = totalPnlCny.plus(pnlCny);
|
||||
|
||||
const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity);
|
||||
const pnlNative = currentNativeValue.minus(holding.totalCostNative);
|
||||
|
||||
result.push({
|
||||
assetId: holding.assetId,
|
||||
symbol: holding.symbol,
|
||||
@ -169,6 +196,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
cnyValue: cnyValue.toString(),
|
||||
totalCostCny: holding.totalCostCny.toString(),
|
||||
pnlCny: pnlCny.toString(),
|
||||
totalCostNative: holding.totalCostNative.toString(),
|
||||
pnlNative: pnlNative.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { db } from '@/db';
|
||||
import { transactions, transactionTypeEnum } from '@/db/schema';
|
||||
import { transactions, transactionTypeEnum, exchangeRates } from '@/db/schema';
|
||||
import { z } from 'zod';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
const createTransactionSchema = z.object({
|
||||
assetId: z.string().uuid(),
|
||||
@ -23,7 +23,21 @@ export async function createTransaction(params: z.infer<typeof createTransaction
|
||||
}
|
||||
|
||||
try {
|
||||
const [transaction] = await db.insert(transactions).values(validation.data).returning();
|
||||
const data = { ...validation.data };
|
||||
if (data.txCurrency !== 'CNY' && (!data.exchangeRate || data.exchangeRate === '1' || data.exchangeRate === '1.00000000')) {
|
||||
const [latestRate] = await db
|
||||
.select({ rate: exchangeRates.rate })
|
||||
.from(exchangeRates)
|
||||
.where(and(
|
||||
eq(exchangeRates.fromCurrency, data.txCurrency),
|
||||
eq(exchangeRates.toCurrency, 'CNY')
|
||||
))
|
||||
.limit(1);
|
||||
if (latestRate) {
|
||||
data.exchangeRate = latestRate.rate;
|
||||
}
|
||||
}
|
||||
const [transaction] = await db.insert(transactions).values(data).returning();
|
||||
return { success: true, data: transaction };
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
23
src/components/assets/sync-button.tsx
Normal file
23
src/components/assets/sync-button.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { syncAllStockPrices } from '@/actions/market';
|
||||
|
||||
export function SyncButton() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleClick() {
|
||||
startTransition(async () => {
|
||||
await syncAllStockPrices();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} disabled={isPending}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isPending ? 'animate-spin' : ''}`} />
|
||||
{isPending ? '同步中...' : '同步股票行情'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { updateAssetPrice } from '@/actions/asset';
|
||||
|
||||
const updatePriceSchema = z.object({
|
||||
newPrice: z.string().min(1, '價格不能為空'),
|
||||
});
|
||||
|
||||
type UpdatePriceForm = z.infer<typeof updatePriceSchema>;
|
||||
|
||||
interface UpdatePriceDialogProps {
|
||||
assetId: string;
|
||||
currentPrice: string;
|
||||
}
|
||||
|
||||
export function UpdatePriceDialog({ assetId, currentPrice }: UpdatePriceDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<UpdatePriceForm>({
|
||||
resolver: zodResolver(updatePriceSchema),
|
||||
defaultValues: {
|
||||
newPrice: currentPrice,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: UpdatePriceForm) {
|
||||
startTransition(async () => {
|
||||
await updateAssetPrice({ assetId, newPrice: values.newPrice });
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
更新市價
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>更新現價</DialogTitle>
|
||||
<DialogDescription>
|
||||
輸入該資產的最新市場單價
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>最新價格</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="輸入價格數值" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? '提交中...' : '確認更新'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -218,9 +218,18 @@ export function AddTransactionDialog({ assets }: AddTransactionDialogProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>交易币种</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<Input placeholder="USD" type="text" {...field} />
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择币种" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="CNY">CNY</SelectItem>
|
||||
<SelectItem value="HKD">HKD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user