添加 lucide-svelte 和 svelte-masonry 依赖,更新 svelte.config.js 以支持新组件路径,优化 Docker 构建流程,增加 BottomBar 组件到布局中
Some checks failed
构建并运行 Docker / build (push) Failing after 52s

This commit is contained in:
2025-07-31 18:31:26 +08:00
parent f96e141b17
commit 51ef838add
10 changed files with 366 additions and 10 deletions

View File

@ -12,11 +12,6 @@ jobs:
- name: 释放内存 - name: 释放内存
run: docker system prune -a -f run: docker system prune -a -f
- name: 设置 Docker Buildx
run: |
docker buildx create --use --name builder
docker buildx inspect --bootstrap
- name: 拉取自定义 checkout action - name: 拉取自定义 checkout action
run: git clone http://114.67.155.184:3000//niyyzf/checkout.git ./.actions/checkout run: git clone http://114.67.155.184:3000//niyyzf/checkout.git ./.actions/checkout
@ -27,7 +22,10 @@ jobs:
persist-credentials: false persist-credentials: false
max-attempts: 5 max-attempts: 5
- name: 构建并部署 - name: 构建 Docker 镜像
run: docker build --cache-from type=gha --cache-to type=gha,mode=max -t watch-stock-list:latest .
- name: 启动/重启服务Docker Compose
run: | run: |
chmod +x build-docker.sh docker compose down || true
./build-docker.sh docker compose up -d --force-recreate

View File

@ -5,6 +5,8 @@
"name": "watch-stock-list", "name": "watch-stock-list",
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.2.13",
"lucide-svelte": "^0.532.0",
"svelte-masonry": "^0.1.5",
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.8.1", "@internationalized/date": "^3.8.1",
@ -450,6 +452,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "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=="], "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=="], "memoize": ["memoize@10.1.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg=="],
@ -530,6 +534,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-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-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=="], "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=="],

View File

@ -41,6 +41,8 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.2.13" "@sveltejs/adapter-node": "^5.2.13",
"lucide-svelte": "^0.532.0",
"svelte-masonry": "^0.1.5"
} }
} }

View File

@ -0,0 +1,119 @@
<script lang="ts">
import { REVIEW_TAGS, PRICE_LABELS, type ReviewTag, type PriceLabel, type ReviewCard } from '$lib/types/review';
let { open = false, onClose, onAdd } = $props<{ open?: boolean; onClose: () => void; onAdd: (card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) => void }>();
let form = $state({
title: '',
content: '',
tags: [] as ReviewTag[],
stock: {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] as PriceLabel }
]
}
});
function addPriceGroup() {
form.stock.priceGroups.push({ price: 0, label: PRICE_LABELS[0] });
}
function removePriceGroup(idx: number) {
if (form.stock.priceGroups.length > 1) {
form.stock.priceGroups.splice(idx, 1);
}
}
function resetForm() {
form.title = '';
form.content = '';
form.tags = [];
form.stock = {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] }
]
};
}
function handleSubmit() {
if (!form.title.trim() || !form.content.trim()) return;
onAdd({
title: form.title,
content: form.content,
tags: [...form.tags],
stock: {
name: form.stock.name,
code: form.stock.code,
amount: form.stock.amount,
priceGroups: form.stock.priceGroups.map(pg => ({ ...pg }))
}
});
resetForm();
onClose();
}
function handleCancel() {
resetForm();
onClose();
}
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-xl p-6 w-full max-w-md relative animate-in fade-in-0 zoom-in-95">
<h2 class="text-lg font-bold mb-4">添加复盘卡片</h2>
<form on:submit|preventDefault={handleSubmit}>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">标题</label>
<input class="input input-bordered w-full" bind:value={form.title} required />
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">内容</label>
<textarea class="input input-bordered w-full min-h-[80px]" bind:value={form.content} required />
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">标签</label>
<div class="flex flex-wrap gap-2">
{#each REVIEW_TAGS as tag}
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" bind:group={form.tags} value={tag} />
<span class="text-xs">{tag}</span>
</label>
{/each}
</div>
</div>
<div class="mb-3">
<label class="block text-sm font-medium mb-1">股票信息</label>
<div class="flex gap-2 mb-2">
<input class="input input-bordered flex-1" placeholder="名称" bind:value={form.stock.name} />
<input class="input input-bordered flex-1" placeholder="代码" bind:value={form.stock.code} />
<input class="input input-bordered w-20" type="number" min="0" placeholder="数量" bind:value={form.stock.amount} />
</div>
<div>
<label class="block text-xs font-medium mb-1">价格组</label>
{#each form.stock.priceGroups as pg, i}
<div class="flex items-center gap-2 mb-1">
<select class="input input-bordered w-24" bind:value={pg.label}>
{#each PRICE_LABELS as label}
<option value={label}>{label}</option>
{/each}
</select>
<input class="input input-bordered w-24" type="number" min="0" placeholder="价格" bind:value={pg.price} />
{#if form.stock.priceGroups.length > 1}
<button type="button" class="btn btn-xs btn-error" on:click={() => removePriceGroup(i)}>移除</button>
{/if}
</div>
{/each}
<button type="button" class="btn btn-xs btn-primary mt-1" on:click={addPriceGroup}>添加价格组</button>
</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button type="button" class="btn btn-ghost" on:click={handleCancel}>取消</button>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
</div>
{/if}

View 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 { addReviewCardStore } from '@/lib/stores/ReviewCardStore';
// 路由配置
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={() => addReviewCardStore.set(true)}
>
<Plus class="w-8 h-8 text-white transition-transform duration-200 group-hover:rotate-90" />
</Button>
</div>
{/if}
{/each}
</div>

View File

@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
import type { ReviewCard } from '../types/review';
// 控制添加卡片弹窗
export const addReviewCardStore = writable(false);
// 复盘卡片列表 store
function createReviewCardStore() {
const { subscribe, set, update } = writable<ReviewCard[]>([]);
return {
subscribe,
set,
reset: () => set([]),
add: (card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) =>
update(cards => [
...cards,
{
...card,
id: crypto.randomUUID(),
starred: false,
createdAt: new Date(),
updatedAt: new Date()
}
]),
remove: (id: string) => update(cards => cards.filter(card => card.id !== id)),
toggleStar: (id: string) => update(cards => cards.map(card => card.id === id ? { ...card, starred: !card.starred } : card)),
updateCard: (id: string, data: Partial<Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt'>>) =>
update(cards => cards.map(card => card.id === id ? { ...card, ...data, updatedAt: new Date() } : card))
};
}
export const reviewCardStore = createReviewCardStore();

32
src/lib/types/review.ts Normal file
View File

@ -0,0 +1,32 @@
// 复盘卡片标签类型
export const REVIEW_TAGS = ['情绪', '策略', '失误', '复盘'] as const;
export type ReviewTag = typeof REVIEW_TAGS[number];
// 价格组类型
export const PRICE_LABELS = ['买入价', '止损价', '目标价', '卖出价'] as const;
export type PriceLabel = typeof PRICE_LABELS[number];
type PriceGroup = {
price: number;
label: PriceLabel;
};
// 股票信息类型
export interface StockInfo {
name: string;
code: string;
amount: number;
priceGroups: PriceGroup[];
}
// 复盘卡片类型
export interface ReviewCard {
id: string;
title: string;
content: string;
tags: ReviewTag[];
stock: StockInfo;
starred: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import BottomBar from '../lib/components/layout/BottomBar.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
{@render children()} {@render children()}
<BottomBar />

View File

@ -0,0 +1,117 @@
<script lang="ts">
// @ts-expect-error: svelte-masonry 没有类型声明
import Masonry from 'svelte-masonry';
import { REVIEW_TAGS, PRICE_LABELS, type ReviewTag, type PriceLabel, type ReviewCard } from '$lib/types/review';
import AddReviewCardForm from '$lib/components/AddReviewCardForm.svelte';
import BottomBar from '$lib/components/layout/BottomBar.svelte';
import { reviewCardStore, addReviewCardStore } from '@/lib/stores/ReviewCardStore';
let showForm = $state(false);
// let openAddForm = $state(false); // 移除 openAddForm 相关代码,表单 open={addReviewCardSignal}
let form = $state({
title: '',
content: '',
tags: [] as ReviewTag[],
stock: {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] as PriceLabel }
]
}
});
function addPriceGroup() {
form.stock.priceGroups.push({ price: 0, label: PRICE_LABELS[0] });
}
function removePriceGroup(idx: number) {
if (form.stock.priceGroups.length > 1) {
form.stock.priceGroups.splice(idx, 1);
}
}
function resetForm() {
form.title = '';
form.content = '';
form.tags = [];
form.stock = {
name: '',
code: '',
amount: 0,
priceGroups: [
{ price: 0, label: PRICE_LABELS[0] }
]
};
}
function handleAddCard() {
// 简单校验
if (!form.title.trim() || !form.content.trim()) return;
reviewCardStore.add({
title: form.title,
content: form.content,
tags: [...form.tags],
stock: {
name: form.stock.name,
code: form.stock.code,
amount: form.stock.amount,
priceGroups: form.stock.priceGroups.map(pg => ({ ...pg }))
}
});
resetForm();
showForm = false;
}
function handleAdd(card: Omit<ReviewCard, 'id' | 'createdAt' | 'updatedAt' | 'starred'>) {
reviewCardStore.add(card);
}
</script>
<div class="mx-auto p-4">
<h1 class="text-2xl font-bold mb-6">瀑布流列表演示</h1>
<Masonry columns="2" gap="16">
{#each $reviewCardStore as card (card.id)}
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-md overflow-hidden mb-4 border border-border">
<div class="p-3">
<div class="font-semibold text-base mb-1">{card.title}</div>
</div>
</div>
{/each}
</Masonry>
</div>
<AddReviewCardForm open={$addReviewCardStore} onAdd={handleAdd} onClose={() => addReviewCardStore.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>

View File

@ -13,7 +13,8 @@ const config = {
out: 'build' out: 'build'
}), }),
alias: { alias: {
'@/*': './src/*' '@/*': './src/*',
'ui/*': './src/lib/components/ui/*'
} }
} }
}; };