Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
e2389e2b6b | |||
922e3d449f | |||
07b2c5e3a6 | |||
1b41d10574 | |||
51ef838add | |||
f96e141b17 | |||
cfdc5e5d9a | |||
745360f93f |
@ -15,4 +15,7 @@ dist
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*~
|
||||
bun.lock
|
||||
.git
|
||||
.gitea
|
12
Dockerfile
12
Dockerfile
@ -8,13 +8,11 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
COPY package.json bun.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f bun.lock ]; then bun install --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
COPY package.json ./
|
||||
# 使用 npm 缓存来加速构建
|
||||
#换源
|
||||
RUN npm config set registry https://registry.npm.taobao.org
|
||||
RUN npm install --prefer-offline --no-audit --no-fund --production=false --legacy-peer-deps
|
||||
|
||||
# 构建阶段
|
||||
FROM base AS builder
|
||||
|
18
build-docker.sh
Normal file
18
build-docker.sh
Normal file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Actions 专用构建脚本
|
||||
set -e
|
||||
|
||||
echo "🚀 开始构建 Docker 镜像..."
|
||||
|
||||
# 使用标准 docker build 命令
|
||||
docker build -t watch-stock-list:latest .
|
||||
|
||||
echo "✅ 构建完成!"
|
||||
|
||||
# 启动容器
|
||||
echo "🐳 启动容器..."
|
||||
docker-compose down || true
|
||||
docker-compose up -d --force-recreate
|
||||
|
||||
echo "🎉 部署完成!应用运行在 http://localhost:7799"
|
23
build.sh
Normal file
23
build.sh
Normal file
@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 本地构建脚本 - 包含缓存优化
|
||||
set -e
|
||||
|
||||
echo "🚀 开始构建 Docker 镜像..."
|
||||
|
||||
# 清理旧镜像(可选)
|
||||
# docker system prune -f
|
||||
|
||||
# 使用 Buildx 构建
|
||||
docker buildx build \
|
||||
--tag watch-stock-list:latest \
|
||||
--load \
|
||||
.
|
||||
|
||||
echo "✅ 构建完成!"
|
||||
|
||||
# 启动容器(可选)
|
||||
echo "🐳 启动容器..."
|
||||
docker-compose up -d
|
||||
|
||||
echo "🎉 部署完成!应用运行在 http://localhost:7799"
|
9
bun.lock
9
bun.lock
@ -5,6 +5,9 @@
|
||||
"name": "watch-stock-list",
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.532.0",
|
||||
"svelte-masonry": "^0.1.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.1",
|
||||
@ -396,6 +399,8 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
@ -450,6 +455,8 @@
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lucide-svelte": ["lucide-svelte@0.532.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-UMhuyPuzmJB0/2xwdhOW3NRK7vuVpqxIvHiH/bhQTu0vsvRw59YbLC65kS6fierPr7FckFV5Y/y/aUAVUZgGhA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"memoize": ["memoize@10.1.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg=="],
|
||||
@ -530,6 +537,8 @@
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-Iz8dFXzBNAM7XlEIsUjUGQhbEE+Pvv9odb9+0+ITTgFWZBGeJRRYqHUUglwe2EkLD5LIsQaAc4IUJyvtKuOO5w=="],
|
||||
|
||||
"svelte-masonry": ["svelte-masonry@0.1.5", "", { "peerDependencies": { "svelte": "^4.0.0" } }, "sha512-ZMtALHVbvriGcLODSdoeRaI0SvbmNEW6puG/Zs2tUP7hK0e/TLrxGTAAtNDinI/8DJhUJUCnAsP5xsTmebLwbw=="],
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="],
|
||||
|
||||
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
||||
|
@ -41,6 +41,9 @@
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.13"
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.532.0",
|
||||
"svelte-masonry": "^0.1.5"
|
||||
}
|
||||
}
|
||||
|
338
src/lib/components/AddReviewCardForm.svelte
Normal file
338
src/lib/components/AddReviewCardForm.svelte
Normal file
@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Plus, X } from 'lucide-svelte';
|
||||
import type { StockInfo } from '$lib/types/review';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let open = false;
|
||||
export let onClose: () => void;
|
||||
export let onAdd: (stockInfo: StockInfo) => void;
|
||||
|
||||
// 默认标签
|
||||
const defaultLabels = [
|
||||
{ label: '买入价', key: 'buy' },
|
||||
{ label: '目标价', key: 'target' },
|
||||
{ label: '止损价', key: 'stop' }
|
||||
];
|
||||
|
||||
let form: StockInfo = {
|
||||
name: '',
|
||||
code: '',
|
||||
tags: [],
|
||||
priceGroups: [],
|
||||
createdAt: ''
|
||||
};
|
||||
// 保证 price 始终为 number 类型
|
||||
let addedPriceGroups: { label: string; price: number }[] = [];
|
||||
let customLabelInput = '';
|
||||
let customPriceInput: string = '';
|
||||
let customLabelError = '';
|
||||
let nameError = '';
|
||||
let codeError = '';
|
||||
let tagInput = '';
|
||||
let tagInputRef: any = null;
|
||||
|
||||
// 从URL参数填充表单数据
|
||||
function fillFormFromUrlParams() {
|
||||
const urlParams = $page.url.searchParams;
|
||||
const code = urlParams.get('code');
|
||||
const name = urlParams.get('name');
|
||||
const tags = urlParams.get('tags');
|
||||
|
||||
if (code) {
|
||||
form.code = code;
|
||||
}
|
||||
if (name) {
|
||||
form.name = name;
|
||||
}
|
||||
if (tags) {
|
||||
// 支持逗号分隔的标签
|
||||
const tagArray = tags.split(',').map(tag => tag.trim()).filter(tag => tag);
|
||||
form.tags = [...form.tags, ...tagArray];
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const trimmedName = form.name.trim();
|
||||
const trimmedCode = form.code.trim();
|
||||
|
||||
nameError = trimmedName ? '' : '股票名称不能为空';
|
||||
codeError = trimmedCode ? '' : '股票代码不能为空';
|
||||
customLabelError = '';
|
||||
|
||||
// 只有当自定义标签有输入时才验证价格
|
||||
if (customLabelInput.trim() && customPriceInput === '') {
|
||||
customLabelError = '请输入价格';
|
||||
}
|
||||
|
||||
return !nameError && !codeError && !customLabelError;
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !form.tags.includes(tag)) {
|
||||
form.tags = [...form.tags, tag];
|
||||
}
|
||||
tagInput = '';
|
||||
if (tagInputRef && typeof tagInputRef.focus === 'function') tagInputRef.focus();
|
||||
}
|
||||
function removeTag(idx: number) {
|
||||
form.tags = form.tags.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
// 添加自带标签
|
||||
function addDefaultLabel(label: string) {
|
||||
if (!addedPriceGroups.some(pg => pg.label === label)) {
|
||||
addedPriceGroups = [...addedPriceGroups, { label, price: 0 }];
|
||||
}
|
||||
}
|
||||
// 添加自定义标签
|
||||
function addCustomPriceGroup() {
|
||||
const label = customLabelInput.trim();
|
||||
const price = Number(customPriceInput);
|
||||
if (!label) {
|
||||
customLabelError = '请输入标签名';
|
||||
return;
|
||||
}
|
||||
if (customPriceInput === '' || isNaN(price)) {
|
||||
customLabelError = '请输入价格';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
defaultLabels.some(l => l.label === label) ||
|
||||
addedPriceGroups.some(pg => pg.label === label)
|
||||
) {
|
||||
customLabelError = '标签名已存在';
|
||||
return;
|
||||
}
|
||||
addedPriceGroups = [...addedPriceGroups, { label, price }];
|
||||
customLabelInput = '';
|
||||
customPriceInput = '';
|
||||
customLabelError = '';
|
||||
}
|
||||
// 删除标签
|
||||
function removePriceGroup(idx: number) {
|
||||
addedPriceGroups = addedPriceGroups.filter((_, i) => i !== idx);
|
||||
}
|
||||
function resetForm() {
|
||||
form = {
|
||||
name: '',
|
||||
code: '',
|
||||
tags: [],
|
||||
priceGroups: [],
|
||||
createdAt: ''
|
||||
};
|
||||
addedPriceGroups = [];
|
||||
customLabelInput = '';
|
||||
customPriceInput = '';
|
||||
customLabelError = '';
|
||||
nameError = '';
|
||||
codeError = '';
|
||||
tagInput = '';
|
||||
}
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
const trimmedCode = form.code.trim();
|
||||
|
||||
// 确保 code 不为空
|
||||
if (!trimmedCode) {
|
||||
codeError = '股票代码不能为空';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stockData = {
|
||||
name: trimmedName,
|
||||
code: trimmedCode,
|
||||
tags: [...form.tags],
|
||||
priceGroups: addedPriceGroups.filter(pg => !isNaN(pg.price) && pg.price > 0),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('表单提交的股票数据:', stockData);
|
||||
await onAdd(stockData);
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('表单提交错误:', error);
|
||||
// 如果是重复股票的错误,显示友好提示
|
||||
if (error instanceof Error && error.message.includes('已存在')) {
|
||||
nameError = '该股票已存在';
|
||||
codeError = '该股票已存在';
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleCancel() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
function handleTagKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}
|
||||
// 当对话框打开时,检查URL参数并填充表单
|
||||
$: if (open) {
|
||||
fillFormFromUrlParams();
|
||||
setTimeout(() => {
|
||||
document.getElementById('stock-name')?.focus();
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open>
|
||||
<DialogContent class="max-w-xl w-full rounded-md p-0 bg-background shadow-2xl">
|
||||
<DialogHeader class="px-8 pt-8 pb-3">
|
||||
<DialogTitle class="text-2xl font-bold tracking-tight mb-1">添加股票信息</DialogTitle>
|
||||
<DialogDescription class="text-base text-muted-foreground mt-1 mb-2">
|
||||
填写股票基本信息和价格标签(可选)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form class="space-y-5 px-8 pb-8 pt-2 font-sans" onsubmit={handleSubmit}>
|
||||
<!-- 股票信息 -->
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-3 tracking-tight">股票信息</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="stock-name" class="mb-1 block text-sm text-muted-foreground font-medium">名称</Label>
|
||||
<Input
|
||||
id="stock-name"
|
||||
bind:value={form.name}
|
||||
placeholder="股票名称"
|
||||
class="h-10 w-full rounded-md px-4 text-sm placeholder:text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-primary border border-input"
|
||||
onblur={() => (nameError = form.name.trim() ? '' : '股票名称不能为空')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if nameError}
|
||||
<span class="text-xs text-red-500 mt-1 block">{nameError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<Label for="stock-code" class="mb-1 block text-sm text-muted-foreground font-medium">代码</Label>
|
||||
<Input
|
||||
id="stock-code"
|
||||
bind:value={form.code}
|
||||
placeholder="股票代码"
|
||||
class="h-10 w-full rounded-md px-4 text-sm placeholder:text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-primary border border-input"
|
||||
onblur={() => (codeError = form.code.trim() ? '' : '股票代码不能为空')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if codeError}
|
||||
<span class="text-xs text-red-500 mt-1 block">{codeError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Label for="stock-tags" class="mb-1 block text-sm text-muted-foreground font-medium">股票标签</Label>
|
||||
<div class="flex flex-wrap gap-2 items-center mb-1">
|
||||
{#each form.tags as tag, i}
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-muted rounded-md shadow-sm text-sm border border-muted-foreground/10 transition hover:bg-primary/20 hover:text-primary-foreground cursor-pointer">
|
||||
{tag}
|
||||
<button type="button" class="ml-1 hover:text-destructive focus:outline-none" tabindex="-1" onclick={() => removeTag(i)}>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="stock-tags"
|
||||
bind:this={tagInputRef}
|
||||
class="w-40 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
|
||||
placeholder="输入标签后回车"
|
||||
bind:value={tagInput}
|
||||
onkeydown={handleTagKeydown}
|
||||
onblur={addTag}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">按回车或逗号添加标签</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-muted-foreground/10 my-3"></div>
|
||||
<!-- 价格标签(可选) -->
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-2 tracking-tight">价格标签 <span class="text-xs text-muted-foreground">(完全可选)</span></div>
|
||||
<div class="flex flex-wrap gap-2 mb-2 items-center">
|
||||
{#each defaultLabels as l}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={addedPriceGroups.some(pg => pg.label === l.label) ? 'secondary' : 'outline'}
|
||||
class="rounded-md hover:bg-primary/10 hover:text-primary-foreground transition-colors font-medium h-8 px-3"
|
||||
disabled={addedPriceGroups.some(pg => pg.label === l.label)}
|
||||
onclick={() => addDefaultLabel(l.label)}
|
||||
>
|
||||
{#if addedPriceGroups.some(pg => pg.label === l.label)}
|
||||
已添加
|
||||
{:else}
|
||||
<Plus class="w-4 h-4 mr-1" />{l.label}
|
||||
{/if}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each addedPriceGroups as pg, i}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center h-8 px-3 bg-muted rounded-md shadow-sm text-sm font-medium leading-none border border-muted-foreground/10 transition hover:bg-primary/20 hover:text-primary-foreground cursor-pointer">
|
||||
{pg.label}
|
||||
<button type="button" class="ml-1 hover:text-destructive focus:outline-none" tabindex="-1" onclick={() => removePriceGroup(i)}>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="请输入价格"
|
||||
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
|
||||
value={pg.price === 0 ? '' : pg.price}
|
||||
oninput={e => pg.price = Number((e.target as HTMLInputElement).value)}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- 自定义标签输入 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
|
||||
placeholder="自定义标签名"
|
||||
bind:value={customLabelInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-32 h-8 text-sm px-2 rounded-md focus-visible:ring-2 focus-visible:ring-primary border border-input"
|
||||
placeholder="请输入价格"
|
||||
bind:value={customPriceInput}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button type="button" size="sm" class="h-8 rounded-md font-medium" onclick={addCustomPriceGroup}>
|
||||
<Plus class="h-4 w-4" /> 添加
|
||||
</Button>
|
||||
</div>
|
||||
{#if customLabelError}
|
||||
<span class="text-xs text-red-500 mt-1 block">{customLabelError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<DialogFooter class="flex flex-row justify-end gap-4 pt-6 pb-2 px-0">
|
||||
<Button variant="outline" type="button" class="min-w-[90px] h-10 rounded-md font-medium" onclick={handleCancel}>取消</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="min-w-[120px] h-10 rounded-md text-base font-semibold"
|
||||
disabled={!form.name.trim() || !form.code.trim() || !validate()}
|
||||
>
|
||||
添加股票
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
54
src/lib/components/StockCard.svelte
Normal file
54
src/lib/components/StockCard.svelte
Normal file
@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { StockInfo } from '$lib/types/review';
|
||||
import { X, Clock } from 'lucide-svelte';
|
||||
|
||||
export let stock: StockInfo;
|
||||
export let onDelete: () => void;
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-md overflow-hidden border border-border p-4">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-lg">{stock.name} <span class="text-sm text-muted-foreground">({stock.code})</span></div>
|
||||
{#if stock.createdAt}
|
||||
<div class="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
||||
<Clock class="w-3 h-3" />
|
||||
<span>{formatDate(stock.createdAt)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onDelete}
|
||||
<button class="text-destructive hover:bg-destructive/10 rounded p-1 ml-2" on:click={onDelete} title="删除">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if stock.tags && stock.tags.length}
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
{#each stock.tags as tag}
|
||||
<span class="inline-block px-2 py-0.5 bg-muted rounded text-xs">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if stock.priceGroups && stock.priceGroups.length}
|
||||
<div class="space-y-1">
|
||||
{#each stock.priceGroups as group}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block px-2 py-0.5 bg-gray-100 rounded text-xs">{group.label}</span>
|
||||
<span class="text-sm">¥{group.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
46
src/lib/components/layout/BottomBar.svelte
Normal file
46
src/lib/components/layout/BottomBar.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Button } from 'ui/button';
|
||||
import { Home, Plus, Search, Settings, Minus } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { addStockStore } from '$lib/stores/StockStore';
|
||||
|
||||
// 路由配置
|
||||
const navs = [
|
||||
{ label: '首页', icon: Home, href: '/' },
|
||||
{ label: '搜索', icon: Search, href: '/search' },
|
||||
{ label: '设置', icon: Settings, href: '/settings' },
|
||||
{ label: '股票', icon: Minus, href: '/list' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 w-[95vw] max-w-md mx-auto rounded-2xl bg-background/95 shadow-2xl border border-border/50 flex items-center gap-0 px-0 h-16 z-50 backdrop-blur-md supports-[backdrop-filter]:bg-background/80">
|
||||
{#each navs as nav, i}
|
||||
<div class="flex flex-1 h-full items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={nav.href}
|
||||
class="group flex flex-col items-center justify-center gap-0.5 w-full h-full rounded-lg border border-transparent transition-all duration-200
|
||||
{get(page).url.pathname === nav.href ? ' text-primary bg-accent/70 shadow-md border-primary/40 ring-2 ring-primary/20' : ' text-muted-foreground hover:bg-accent/60 hover:shadow-md hover:border-primary/40 hover:ring-2 hover:ring-primary/20 focus-visible:ring-2 focus-visible:ring-primary/30'}"
|
||||
aria-label={nav.label}
|
||||
>
|
||||
<svelte:component this={nav.icon} class="w-4 h-4 transition-all duration-200 group-hover:-translate-y-1 group-hover:scale-110 group-hover:text-primary group-hover:drop-shadow {get(page).url.pathname === nav.href ? 'text-primary' : ''}" />
|
||||
<span class="text-xs font-medium transition-all duration-200 group-hover:text-primary group-hover:scale-105 {get(page).url.pathname === nav.href ? 'text-primary' : ''}">{nav.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if i === 1}
|
||||
<div class="flex flex-1 h-full items-center justify-center relative">
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
class="rounded-full w-16 h-16 shadow-2xl border-4 border-background bg-gradient-to-br from-primary via-primary to-primary/90 flex items-center justify-center transition-all duration-300 hover:scale-110 hover:shadow-[0_20px_40px_rgba(0,0,0,0.3)] active:scale-95 focus-visible:ring-4 focus-visible:ring-primary/20 absolute left-1/2 -translate-x-1/2 -top-6 z-10"
|
||||
aria-label="添加"
|
||||
onclick={() => addStockStore.set(true)}
|
||||
>
|
||||
<Plus class="w-8 h-8 text-white transition-transform duration-200 group-hover:rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
166
src/lib/stores/StockStore.ts
Normal file
166
src/lib/stores/StockStore.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { StockInfo } from '$lib/types/review';
|
||||
|
||||
// 数据库配置
|
||||
const DB_NAME = 'StockWatchDB';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'stocks';
|
||||
|
||||
// 初始化数据库
|
||||
async function initDB() {
|
||||
console.log('初始化数据库...');
|
||||
const db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
console.log('数据库升级,创建对象存储...');
|
||||
// 创建 stocks 存储,使用 code 作为主键
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'code' });
|
||||
// 创建索引以便按时间排序
|
||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
||||
console.log('对象存储创建成功,keyPath:', 'code');
|
||||
}
|
||||
},
|
||||
});
|
||||
console.log('数据库初始化完成');
|
||||
return db;
|
||||
}
|
||||
|
||||
// 数据库操作函数
|
||||
async function loadStocksFromDB(): Promise<StockInfo[]> {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const stocks = await store.getAll();
|
||||
return stocks;
|
||||
} catch (error) {
|
||||
console.error('加载股票数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStockToDB(stock: StockInfo): Promise<void> {
|
||||
try {
|
||||
console.log('准备保存股票数据:', stock);
|
||||
|
||||
// 验证股票代码是否存在且不为空
|
||||
if (!stock.code || stock.code.trim() === '') {
|
||||
console.error('股票代码为空:', stock);
|
||||
throw new Error('股票代码不能为空');
|
||||
}
|
||||
|
||||
const db = await initDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
// 检查是否已存在相同代码的股票
|
||||
const existingStock = await store.get(stock.code);
|
||||
if (existingStock) {
|
||||
throw new Error(`股票代码 ${stock.code} 已存在`);
|
||||
}
|
||||
|
||||
console.log('添加到数据库:', stock);
|
||||
await store.add(stock);
|
||||
await tx.done;
|
||||
console.log('股票数据保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存股票数据失败:', error);
|
||||
console.error('股票数据:', stock);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStockFromDB(code: string): Promise<void> {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
await store.delete(code);
|
||||
await tx.done;
|
||||
} catch (error) {
|
||||
console.error('删除股票数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllStocksFromDB(): Promise<void> {
|
||||
try {
|
||||
const db = await initDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
await store.clear();
|
||||
await tx.done;
|
||||
} catch (error) {
|
||||
console.error('清空股票数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建增强的 store
|
||||
function createStockStore() {
|
||||
const { subscribe, set, update } = writable<StockInfo[]>([]);
|
||||
|
||||
// 初始化时从数据库加载数据
|
||||
let initialized = false;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// 初始化 store
|
||||
async init() {
|
||||
if (!initialized) {
|
||||
const stocks = await loadStocksFromDB();
|
||||
set(stocks);
|
||||
initialized = true;
|
||||
}
|
||||
},
|
||||
|
||||
// 添加股票
|
||||
async add(stock: StockInfo) {
|
||||
try {
|
||||
await saveStockToDB(stock);
|
||||
update(list => [...list, stock]);
|
||||
} catch (error) {
|
||||
console.error('添加股票失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除股票(通过股票代码)
|
||||
async remove(code: string) {
|
||||
try {
|
||||
await removeStockFromDB(code);
|
||||
update(list => list.filter(stock => stock.code !== code));
|
||||
} catch (error) {
|
||||
console.error('删除股票失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 重置所有数据
|
||||
async reset() {
|
||||
try {
|
||||
await clearAllStocksFromDB();
|
||||
set([]);
|
||||
} catch (error) {
|
||||
console.error('重置数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 直接设置数据(用于测试或导入)
|
||||
set,
|
||||
|
||||
// 更新数据(用于编辑)
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
export const stockStore = createStockStore();
|
||||
export const addStockStore = writable(false);
|
||||
|
||||
// 在浏览器环境中自动初始化
|
||||
if (typeof window !== 'undefined') {
|
||||
stockStore.init();
|
||||
}
|
14
src/lib/types/review.ts
Normal file
14
src/lib/types/review.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// 股票信息类型和自定义价格标签类型
|
||||
|
||||
export type CustomPriceGroup = {
|
||||
label: string; // 价格标签名
|
||||
price: number; // 价格
|
||||
};
|
||||
|
||||
export interface StockInfo {
|
||||
name: string; // 股票名称
|
||||
code: string; // 股票代码(唯一标识符)
|
||||
tags: string[]; // 股票标签
|
||||
priceGroups: CustomPriceGroup[]; // 价格标签+价格数组
|
||||
createdAt: string; // 添加时间
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import BottomBar from '../lib/components/layout/BottomBar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
<BottomBar />
|
||||
|
70
src/routes/list/+page.svelte
Normal file
70
src/routes/list/+page.svelte
Normal file
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
// @ts-expect-error: svelte-masonry 没有类型声明
|
||||
import Masonry from 'svelte-masonry';
|
||||
import AddReviewCardForm from '$lib/components/AddReviewCardForm.svelte';
|
||||
import StockCard from '$lib/components/StockCard.svelte';
|
||||
import BottomBar from '$lib/components/layout/BottomBar.svelte';
|
||||
import { stockStore, addStockStore } from '$lib/stores/StockStore';
|
||||
import type { StockInfo } from '$lib/types/review';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
async function handleAdd(stock: StockInfo) {
|
||||
try {
|
||||
console.log('页面组件接收到的股票数据:', stock);
|
||||
await stockStore.add(stock);
|
||||
} catch (error) {
|
||||
console.error('添加股票失败:', error);
|
||||
toast.error('添加股票失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(stock: StockInfo) {
|
||||
try {
|
||||
await stockStore.remove(stock.code);
|
||||
} catch (error) {
|
||||
console.error('删除股票失败:', error);
|
||||
toast.error('删除股票失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-6">股票列表</h1>
|
||||
<Masonry columns="2" gap="16">
|
||||
{#each $stockStore as stock, idx (stock.name + stock.code)}
|
||||
<StockCard {stock} onDelete={() => handleDelete(stock)} />
|
||||
{/each}
|
||||
</Masonry>
|
||||
</div>
|
||||
|
||||
<AddReviewCardForm open={$addStockStore} onAdd={handleAdd} onClose={() => addStockStore.set(false)} />
|
||||
<BottomBar />
|
||||
|
||||
<style>
|
||||
:global(html) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tw-prose-invert) var(--background);
|
||||
}
|
||||
:global(*::-webkit-scrollbar) {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
:global(*::-webkit-scrollbar-thumb) {
|
||||
background: rgba(120,120,120,0.18);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
:global(*:hover::-webkit-scrollbar-thumb) {
|
||||
background: rgba(120,120,120,0.32);
|
||||
}
|
||||
:global(*::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
:global(.masonry) {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
@ -13,7 +13,8 @@ const config = {
|
||||
out: 'build'
|
||||
}),
|
||||
alias: {
|
||||
'@/*': './src/*'
|
||||
'@/*': './src/*',
|
||||
'ui/*': './src/lib/components/ui/*'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user