first commit

This commit is contained in:
2026-09-11 15:49:41 +08:00
commit 5b69bc818a
98 changed files with 30551 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function render<C extends keyof LiveContentConfig['collections']>(
entry: import('astro').LiveDataEntry<LiveLoaderDataType<C>>,
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = never;
}
+14
View File
@@ -0,0 +1,14 @@
{
"pid": 27812,
"port": 4321,
"url": "http://localhost:4321",
"urls": {
"local": [
"http://localhost:4321/"
],
"network": [],
"networkInterfaceNames": []
},
"background": true,
"startedAt": "2026-09-11T07:43:31.316Z"
}
@@ -0,0 +1 @@
/// <reference types="@astrojs/cloudflare/types.d.ts" />
+5
View File
@@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1789106959098
}
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="astro/client" />
/// <reference path="integrations/_astrojs_cloudflare/cloudflare.d.ts" />
/// <reference path="content.d.ts" />
+1
View File
@@ -0,0 +1 @@
ADMIN_PASSWORD=改成你自己嘅密碼
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.wrangler/
.dev.vars
*.log
.DS_Store
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

@@ -0,0 +1,238 @@
- generic [active] [ref=f22e1]:
- generic [ref=f22e2]:
- banner [ref=f22e3]:
- generic [ref=f22e5]:
- link [ref=f22e6] [cursor=pointer]:
- /url: /
- paragraph [ref=f22e7]: 盈豐太陽能
- button "開啟選單" [ref=f22e9] [cursor=pointer]
- main [ref=f22e11]:
- generic [ref=f22e14]:
- generic [ref=f22e15]:
- paragraph [ref=f22e18]: 香港村屋太陽能專家
- heading "善用村屋天台 發電慳電賺回報" [level=1] [ref=f22e19]
- paragraph [ref=f22e20]: 由現場評估、方案建議、安裝施工,到申請同跟進,我哋用清晰易明嘅方式,陪你完成整個村屋太陽能項目。
- generic [ref=f22e21]:
- link "免費預約評估" [ref=f22e22] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "睇完成案例" [ref=f22e25] [cursor=pointer]:
- /url: "#cases"
- generic [ref=f22e28]:
- paragraph [ref=f22e33]: 註冊電業承辦商
- paragraph [ref=f22e38]: 舊客口碑介紹
- paragraph [ref=f22e43]: 一對一專人跟進
- generic [ref=f22e44]:
- img "村屋天台太陽能系統" [ref=f22e45]
- generic [ref=f22e46]:
- generic [ref=f22e47]:
- paragraph [ref=f22e48]: 一般回本期
- paragraph [ref=f22e49]: 約 69 年
- generic [ref=f22e50]:
- paragraph [ref=f22e51]: 系統可用年期
- paragraph [ref=f22e52]: 2025 年
- generic [ref=f22e53]:
- paragraph [ref=f22e54]: 牌照
- paragraph [ref=f22e55]: 電業承辦商編號:待填
- generic [ref=f22e58]:
- generic [ref=f22e64]:
- paragraph [ref=f22e65]: 註冊電業承辦商
- paragraph [ref=f22e66]: 合資格承辦,安全合法
- generic [ref=f22e73]:
- paragraph [ref=f22e74]: 上網電價 FiT 代辦
- paragraph [ref=f22e75]: 中電/港燈申請全程跟進
- generic [ref=f22e81]:
- paragraph [ref=f22e82]: 認可人士 AP 簽發
- paragraph [ref=f22e83]: C/PVS、WR1、GF1 文件
- generic [ref=f22e89]:
- paragraph [ref=f22e90]: 2025 年系統壽命
- paragraph [ref=f22e91]: 優質器材,長遠穩定
- generic [ref=f22e93]:
- generic [ref=f22e94]:
- paragraph [ref=f22e97]: 服務範圍
- heading "專為村屋而設嘅太陽能方案" [level=2] [ref=f22e98]
- paragraph [ref=f22e99]: 由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。
- generic [ref=f22e100]:
- group [ref=f22e101]:
- paragraph [ref=f22e102]: "01"
- heading "村屋天台太陽能系統" [level=3] [ref=f22e103]
- paragraph [ref=f22e104]: 善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。
- group [ref=f22e105]:
- paragraph [ref=f22e106]: "02"
- heading "合法合規支架工程" [level=3] [ref=f22e107]
- paragraph [ref=f22e108]: 按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。
- group [ref=f22e109]:
- paragraph [ref=f22e110]: "03"
- heading "代辦申請及文件" [level=3] [ref=f22e111]
- paragraph [ref=f22e112]: 由中電/港燈申請、上網電價計劃(FiT)到相關政府文件,全程代辦。
- group [ref=f22e113]:
- paragraph [ref=f22e114]: "04"
- heading "專業安裝及驗收" [level=3] [ref=f22e115]
- paragraph [ref=f22e116]: 由認可人士(AP)及註冊電工施工,簽發所需認證文件,安全合法。
- group [ref=f22e117]:
- paragraph [ref=f22e118]: "05"
- heading "降低頂樓室溫" [level=3] [ref=f22e119]
- paragraph [ref=f22e120]: 太陽能板為天台遮蔭,有效降低頂樓單位溫度,同時慳冷氣電費。
- group [ref=f22e121]:
- paragraph [ref=f22e122]: "06"
- heading "收益及回本分析" [level=3] [ref=f22e123]
- paragraph [ref=f22e124]: 按日照、面積同電價,提供清晰嘅發電量同回本估算,一目了然。
- generic [ref=f22e126]:
- generic [ref=f22e127]:
- paragraph [ref=f22e130]: 點解揀我哋
- heading "結合專業工程同貼心溝通" [level=2] [ref=f22e131]
- paragraph [ref=f22e132]: 我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。
- generic [ref=f22e133]:
- generic [ref=f22e134]:
- paragraph [ref=f22e135]: "01"
- heading "熟悉香港村屋環境" [level=3] [ref=f22e136]
- paragraph [ref=f22e137]: 深耕本地市場,充分掌握村屋結構、法規同地理限制。
- generic [ref=f22e138]:
- paragraph [ref=f22e139]: "02"
- heading "流程清晰易明" [level=3] [ref=f22e140]
- paragraph [ref=f22e141]: 用最簡單直白嘅方式解說回本期同技術細節,絕無花巧術語。
- generic [ref=f22e142]:
- paragraph [ref=f22e143]: "03"
- heading "專人快速跟進" [level=3] [ref=f22e144]
- paragraph [ref=f22e145]: 一對一顧問服務,由評估到完工全程透明,隨時解答疑問。
- generic [ref=f22e146]:
- paragraph [ref=f22e147]: "04"
- heading "美觀與效率兼備" [level=3] [ref=f22e148]
- paragraph [ref=f22e149]: 追求發電效率之餘,極致重視施工整齊度同建築美感。
- generic [ref=f22e150]:
- paragraph [ref=f22e151]: "05"
- heading "重視施工安全" [level=3] [ref=f22e152]
- paragraph [ref=f22e153]: 嚴選耐用器材、嚴格執行安全程序,確保系統長久穩定。
- generic [ref=f22e154]:
- paragraph [ref=f22e155]: "06"
- heading "不硬銷真誠建議" [level=3] [ref=f22e156]
- paragraph [ref=f22e157]: 先幫你了解安裝可行性,未必即刻做,最緊要係問清楚。
- generic [ref=f22e159]:
- generic [ref=f22e160]:
- paragraph [ref=f22e163]: 服務流程
- heading "專業安裝流程 5 步到位" [level=2] [ref=f22e164]
- paragraph [ref=f22e165]: 清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。
- generic [ref=f22e167]:
- generic [ref=f22e168]:
- generic [ref=f22e169]: "01"
- paragraph [ref=f22e170]: 初步諮詢 / 現場評估
- paragraph [ref=f22e171]: 專人上門視察環境,提供專業技術建議。
- generic [ref=f22e172]:
- generic [ref=f22e173]: "02"
- paragraph [ref=f22e174]: 申請文件處理
- paragraph [ref=f22e175]: 代辦電力公司同政府部門所需嘅申請文件。
- generic [ref=f22e176]:
- generic [ref=f22e177]: "03"
- paragraph [ref=f22e178]: 安裝施工
- paragraph [ref=f22e179]: 專業團隊進場施工,確保工期同施工品質安全。
- generic [ref=f22e180]:
- generic [ref=f22e181]: "04"
- paragraph [ref=f22e182]: 驗收認證
- paragraph [ref=f22e183]: 由認可人士及合資格電工簽發 C/PVS、WR1、GF1 等文件。
- generic [ref=f22e184]:
- generic [ref=f22e185]: "05"
- paragraph [ref=f22e186]: 掛表正式發電
- paragraph [ref=f22e187]: 電力公司掛上電表後,正式開始享受太陽能發電收益。
- generic [ref=f22e189]:
- generic [ref=f22e190]:
- paragraph [ref=f22e193]: 完成案例
- heading "真實村屋工程案例" [level=2] [ref=f22e194]
- paragraph [ref=f22e195]: 我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。
- generic [ref=f22e196]:
- group [ref=f22e197]:
- img "大埔上碗窯 — 雙玻光伏板 航空鋁支架" [ref=f22e199]
- generic [ref=f22e200]:
- generic [ref=f22e201]:
- paragraph [ref=f22e202]: 2026年2月
- paragraph [ref=f22e207]: 大埔半山
- heading "大埔上碗窯 — 雙玻光伏板 航空鋁支架" [level=3] [ref=f22e208]
- paragraph [ref=f22e209]: 採用雙玻光伏板配航空級鋁合金支架,兼顧發電效率同天台景觀。
- generic [ref=f22e210]:
- group [ref=f22e211]:
- img "元朗山下村 — 香檳色鋁合金支架" [ref=f22e213]
- generic [ref=f22e214]:
- paragraph [ref=f22e215]: 2025年7月
- paragraph [ref=f22e220]: 元朗屏山
- heading "元朗山下村 — 香檳色鋁合金支架" [level=3] [ref=f22e221]
- paragraph [ref=f22e222]: 香檳色支架配合村屋外牆色調,施工整齊,發電穩定。
- group [ref=f22e223]:
- img "西貢西澳村 — 焗白色鋁合金支架" [ref=f22e225]
- generic [ref=f22e226]:
- paragraph [ref=f22e227]: 2025年5月
- paragraph [ref=f22e232]: 新界西貢
- heading "西貢西澳村 — 焗白色鋁合金支架" [level=3] [ref=f22e233]
- paragraph [ref=f22e234]: 焗白色支架低調美觀,善用天台每一寸可用空間。
- generic [ref=f22e236]:
- generic [ref=f22e237]:
- paragraph [ref=f22e240]: 常見問題
- heading "安裝前,你可能想知嘅事" [level=2] [ref=f22e241]
- paragraph [ref=f22e242]: 仲有其他疑問?歡迎隨時 WhatsApp 問我哋。
- generic [ref=f22e244]:
- generic [ref=f22e245]:
- button [expanded] [ref=f22e246]:
- paragraph [ref=f22e247]: 村屋天台可以安裝太陽能嗎?
- region "村屋天台可以安裝太陽能嗎?" [ref=f22e250]:
- generic [ref=f22e251]: 大部分村屋天台都可以。我哋會先上門評估結構、日照同遮擋情況,再判斷可行性同建議方案。
- button [ref=f22e253]:
- paragraph [ref=f22e254]: 安裝太陽能大概幾錢?幾時回本?
- button [ref=f22e258]:
- paragraph [ref=f22e259]: 需要幾大天台面積?
- button [ref=f22e263]:
- paragraph [ref=f22e264]: 安裝太陽能合唔合法?會唔會影響樓宇結構?
- button [ref=f22e268]:
- paragraph [ref=f22e269]: 系統需要保養嗎?
- button [ref=f22e273]:
- paragraph [ref=f22e274]: 申請上網電價(FiT)麻煩嗎?
- generic [ref=f22e279]:
- generic [ref=f22e280]:
- paragraph [ref=f22e283]: 免費評估
- heading "立即預約免費評估" [level=2] [ref=f22e284]
- paragraph [ref=f22e285]: 想知你嘅村屋天台啱唔啱裝太陽能?歡迎 WhatsApp 或致電,我哋會用清晰易明嘅方式為你詳答。
- generic [ref=f22e286]:
- link "WhatsApp 查詢" [ref=f22e287] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "致電我哋" [ref=f22e290] [cursor=pointer]:
- /url: tel:+85200000000
- generic [ref=f22e293]:
- paragraph [ref=f22e298]: +852 0000 0000
- paragraph [ref=f22e304]: [email protected]
- paragraph [ref=f22e310]: 香港新界(地址待填)
- contentinfo [ref=f22e311]:
- generic [ref=f22e312]:
- generic [ref=f22e313]:
- generic [ref=f22e314]:
- paragraph [ref=f22e315]: 盈豐太陽能
- paragraph [ref=f22e316]: 專營香港村屋太陽能系統,由評估、設計、申請到安裝驗收,一站式為你善用天台,賺取發電回報。
- paragraph [ref=f22e317]: 電業承辦商編號:待填
- generic [ref=f22e318]:
- generic [ref=f22e319]:
- paragraph [ref=f22e320]: 快速連結
- link "服務範圍" [ref=f22e321] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f22e322] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f22e323] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f22e324] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f22e325] [cursor=pointer]:
- /url: /blog
- generic [ref=f22e326]:
- paragraph [ref=f22e327]: 聯絡我哋
- link "+852 0000 0000" [ref=f22e331] [cursor=pointer]:
- /url: tel:+85200000000
- link "WhatsApp" [ref=f22e335] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "[email protected]" [ref=f22e340] [cursor=pointer]:
- /url: mailto:[email protected]?subject=%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D
- paragraph [ref=f22e345]: 香港新界(地址待填)
- generic [ref=f22e347]:
- paragraph [ref=f22e348]: © 2026 盈豐太陽能工程有限公司 All rights reserved.
- link "管理後台" [ref=f22e349] [cursor=pointer]:
- /url: /admin
- link "WhatsApp 查詢" [ref=f22e350] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- generic [ref=f22e355]:
- button [ref=f22e356]
- button [ref=f22e362]
- button [ref=f22e366]
- button [ref=f22e371]
@@ -0,0 +1,77 @@
- generic [active] [ref=f23e1]:
- generic [ref=f23e2]:
- banner [ref=f23e3]:
- generic [ref=f23e5]:
- link [ref=f23e6] [cursor=pointer]:
- /url: /
- paragraph [ref=f23e7]: 盈豐太陽能
- paragraph [ref=f23e8]: 電業承辦商編號:待填
- generic [ref=f23e9]:
- link "服務" [ref=f23e10] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f23e11] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f23e12] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f23e13] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f23e14] [cursor=pointer]:
- /url: /blog
- generic [ref=f23e15]:
- link "+852 0000 0000" [ref=f23e16] [cursor=pointer]:
- /url: tel:+85200000000
- link "免費諮詢" [ref=f23e19] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- main [ref=f23e22]:
- generic [ref=f23e23]:
- generic [ref=f23e24]:
- paragraph [ref=f23e27]: BLOG
- heading "太陽能知識庫" [level=1] [ref=f23e28]
- paragraph [ref=f23e29]: 村屋太陽能嘅實用資訊、安裝心得同回本分析,幫你更了解點樣善用天台。
- group [ref=f23e31] [cursor=pointer]:
- img "村屋安裝太陽能前,你要知嘅 5 件事" [ref=f23e33]
- generic [ref=f23e34]:
- generic [ref=f23e35]: 2026年9月11日
- heading "村屋安裝太陽能前,你要知嘅 5 件事" [level=2] [ref=f23e38]
- paragraph [ref=f23e39]: 想喺村屋天台裝太陽能?由天台面積、日照、支架法規到回本期,呢篇幫你一次過搞清楚。
- paragraph [ref=f23e40]: 閱讀更多
- contentinfo [ref=f23e43]:
- generic [ref=f23e44]:
- generic [ref=f23e45]:
- generic [ref=f23e46]:
- paragraph [ref=f23e47]: 盈豐太陽能
- paragraph [ref=f23e48]: 專營香港村屋太陽能系統,由評估、設計、申請到安裝驗收,一站式為你善用天台,賺取發電回報。
- paragraph [ref=f23e49]: 電業承辦商編號:待填
- generic [ref=f23e50]:
- generic [ref=f23e51]:
- paragraph [ref=f23e52]: 快速連結
- link "服務範圍" [ref=f23e53] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f23e54] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f23e55] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f23e56] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f23e57] [cursor=pointer]:
- /url: /blog
- generic [ref=f23e58]:
- paragraph [ref=f23e59]: 聯絡我哋
- link "+852 0000 0000" [ref=f23e63] [cursor=pointer]:
- /url: tel:+85200000000
- link "WhatsApp" [ref=f23e67] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "[email protected]" [ref=f23e72] [cursor=pointer]:
- /url: mailto:[email protected]?subject=%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D
- paragraph [ref=f23e77]: 香港新界(地址待填)
- generic [ref=f23e79]:
- paragraph [ref=f23e80]: © 2026 盈豐太陽能工程有限公司 All rights reserved.
- link "管理後台" [ref=f23e81] [cursor=pointer]:
- /url: /admin
- link "WhatsApp 查詢" [ref=f23e82] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- generic [ref=f23e87]:
- button [ref=f23e88]
- button [ref=f23e94]
- button [ref=f23e98]
- button [ref=f23e103]
@@ -0,0 +1,14 @@
- generic [ref=f25e3]:
- generic [ref=f25e4]:
- checkbox "Use dark theme" [ref=f25e5]
- generic [ref=f25e6] [cursor=pointer]: Use dark theme
- banner [ref=f25e16]:
- generic [ref=f25e17]:
- heading [level=2]
- heading "An error occurred." [level=1] [ref=f25e18]
- generic [ref=f25e24]: "The file does not exist at \"/Users/philipcheung/Documents/projects/yingfungsolar/node_modules/.vite/deps_ssr/marked.js?v=c35ab5a8\" which is in the optimize deps directory. The dependency might be incompatible with the dep optimizer. Try adding it to `optimizeDeps.exclude`."
- generic [ref=f25e30]:
- generic [ref=f25e31]:
- heading "Stack Trace" [level=2] [ref=f25e32]
- generic [ref=f25e33] [cursor=pointer]
- generic [ref=f25e38]: at runInRunnerObject (workers/runner-worker/index.js:107:3) at Object.getComponentByRoute (/Users/philipcheung/Documents/projects/yingfungsolar/node_modules/astro/dist/core/environment/dev-nonrunnable.js:48:10) at matchRoute (/Users/philipcheung/Documents/projects/yingfungsolar/node_modules/astro/dist/core/routing/dev.js:31:14) at DevFacadeApp.devMatch (/Users/philipcheung/Documents/projects/yingfungsolar/node_modules/astro/dist/core/app/dev-facade.js:24:26) at Object.handle [as fetch] (/Users/philipcheung/Documents/projects/yingfungsolar/node_modules/@astrojs/cloudflare/dist/utils/handler.js:66:20) at maybeCaptureError (workers/runner-worker/index.js:51:10)
@@ -0,0 +1,81 @@
- generic [ref=f27e2]:
- banner [ref=f27e3]:
- generic [ref=f27e5]:
- link [ref=f27e6] [cursor=pointer]:
- /url: /
- paragraph [ref=f27e7]: 盈豐太陽能
- paragraph [ref=f27e8]: 電業承辦商編號:待填
- generic [ref=f27e9]:
- link "服務" [ref=f27e10] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f27e11] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f27e12] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f27e13] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f27e14] [cursor=pointer]:
- /url: /blog
- generic [ref=f27e15]:
- link "+852 0000 0000" [ref=f27e16] [cursor=pointer]:
- /url: tel:+85200000000
- link "免費諮詢" [ref=f27e19] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- main [ref=f27e22]:
- generic [ref=f27e23]:
- link "返去 Blog" [ref=f27e24] [cursor=pointer]:
- /url: /blog
- generic [ref=f27e27]:
- heading "村屋安裝太陽能前,你要知嘅 5 件事" [level=1] [ref=f27e28]
- paragraph [ref=f27e29]: 2026年9月11日
- img "村屋安裝太陽能前,你要知嘅 5 件事" [ref=f27e32]
- generic [ref=f27e33]:
- paragraph [ref=f27e34]: 村屋天台係香港少數可以合法大規模安裝太陽能嘅地方。不過落決定之前,以下 5 點你一定要知。
- heading "1. 天台面積有幾大?" [level=2] [ref=f27e35]
- paragraph [ref=f27e36]: 一般每 1 kW 系統約需 60–80 平方呎可用面積。天台有無水箱、樓梯屋或其他遮擋,都會影響實際可安裝容量。
- heading "2. 日照同遮擋" [level=2] [ref=f27e37]
- paragraph [ref=f27e38]: 向南、無遮擋嘅天台發電表現最好。我哋會用現場評估同工具分析你屋企嘅日照情況。
- heading "3. 支架要合法合規" [level=2] [ref=f27e39]
- paragraph [ref=f27e40]: 村屋太陽能支架須符合相關法規。專業設計嘅鋁合金支架唔單止穩固,亦確保合乎要求。
- heading "4. 回本期有幾長?" [level=2] [ref=f27e41]
- paragraph [ref=f27e42]: 一般村屋系統回本期約 6–9 年,之後嘅發電收益基本上係淨賺。實際數字視乎系統容量同電價。
- heading "5. 邊個幫你申請?" [level=2] [ref=f27e43]
- paragraph [ref=f27e44]: 由中電/港燈嘅上網電價計劃(FiT)到政府文件,我哋會全程代辦,你唔需要自己四圍撲。
- paragraph [ref=f27e45]: 想知你屋企啱唔啱裝?歡迎 WhatsApp 我哋預約免費評估。
- link "睇更多文章" [ref=f27e46] [cursor=pointer]:
- /url: /blog
- contentinfo [ref=f27e49]:
- generic [ref=f27e50]:
- generic [ref=f27e51]:
- generic [ref=f27e52]:
- paragraph [ref=f27e53]: 盈豐太陽能
- paragraph [ref=f27e54]: 專營香港村屋太陽能系統,由評估、設計、申請到安裝驗收,一站式為你善用天台,賺取發電回報。
- paragraph [ref=f27e55]: 電業承辦商編號:待填
- generic [ref=f27e56]:
- generic [ref=f27e57]:
- paragraph [ref=f27e58]: 快速連結
- link "服務範圍" [ref=f27e59] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f27e60] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f27e61] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f27e62] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f27e63] [cursor=pointer]:
- /url: /blog
- generic [ref=f27e64]:
- paragraph [ref=f27e65]: 聯絡我哋
- link "+852 0000 0000" [ref=f27e69] [cursor=pointer]:
- /url: tel:+85200000000
- link "WhatsApp" [ref=f27e73] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "[email protected]" [ref=f27e78] [cursor=pointer]:
- /url: mailto:[email protected]?subject=%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D
- paragraph [ref=f27e83]: 香港新界(地址待填)
- generic [ref=f27e85]:
- paragraph [ref=f27e86]: © 2026 盈豐太陽能工程有限公司 All rights reserved.
- link "管理後台" [ref=f27e87] [cursor=pointer]:
- /url: /admin
- link "WhatsApp 查詢" [ref=f27e88] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
@@ -0,0 +1,254 @@
- generic [active] [ref=f28e1]:
- generic [ref=f28e2]:
- banner [ref=f28e3]:
- generic [ref=f28e5]:
- link [ref=f28e6] [cursor=pointer]:
- /url: /
- paragraph [ref=f28e7]: 盈豐太陽能
- paragraph [ref=f28e8]: 電業承辦商編號:待填
- generic [ref=f28e9]:
- link "服務" [ref=f28e10] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f28e11] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f28e12] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f28e13] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f28e14] [cursor=pointer]:
- /url: /blog
- generic [ref=f28e15]:
- link "+852 0000 0000" [ref=f28e16] [cursor=pointer]:
- /url: tel:+85200000000
- link "免費諮詢" [ref=f28e19] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- main [ref=f28e22]:
- generic [ref=f28e25]:
- generic [ref=f28e26]:
- paragraph [ref=f28e29]: 香港村屋太陽能專家
- heading "善用村屋天台 發電慳電賺回報" [level=1] [ref=f28e30]
- paragraph [ref=f28e31]: 由現場評估、方案建議、安裝施工,到申請同跟進,我哋用清晰易明嘅方式,陪你完成整個村屋太陽能項目。
- generic [ref=f28e32]:
- link "免費預約評估" [ref=f28e33] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "睇完成案例" [ref=f28e36] [cursor=pointer]:
- /url: "#cases"
- generic [ref=f28e39]:
- paragraph [ref=f28e44]: 註冊電業承辦商
- paragraph [ref=f28e49]: 舊客口碑介紹
- paragraph [ref=f28e54]: 一對一專人跟進
- generic [ref=f28e55]:
- img "村屋天台太陽能系統" [ref=f28e56]
- generic [ref=f28e57]:
- generic [ref=f28e58]:
- paragraph [ref=f28e59]: 一般回本期
- paragraph [ref=f28e60]: 約 69 年
- generic [ref=f28e61]:
- paragraph [ref=f28e62]: 系統可用年期
- paragraph [ref=f28e63]: 2025 年
- generic [ref=f28e64]:
- paragraph [ref=f28e65]: 牌照
- paragraph [ref=f28e66]: 電業承辦商編號:待填
- generic [ref=f28e69]:
- generic [ref=f28e75]:
- paragraph [ref=f28e76]: 註冊電業承辦商
- paragraph [ref=f28e77]: 合資格承辦,安全合法
- generic [ref=f28e84]:
- paragraph [ref=f28e85]: 上網電價 FiT 代辦
- paragraph [ref=f28e86]: 中電/港燈申請全程跟進
- generic [ref=f28e92]:
- paragraph [ref=f28e93]: 認可人士 AP 簽發
- paragraph [ref=f28e94]: C/PVS、WR1、GF1 文件
- generic [ref=f28e100]:
- paragraph [ref=f28e101]: 2025 年系統壽命
- paragraph [ref=f28e102]: 優質器材,長遠穩定
- generic [ref=f28e104]:
- generic [ref=f28e105]:
- paragraph [ref=f28e108]: 服務範圍
- heading "專為村屋而設嘅太陽能方案" [level=2] [ref=f28e109]
- paragraph [ref=f28e110]: 由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。
- generic [ref=f28e111]:
- group [ref=f28e112]:
- paragraph [ref=f28e113]: "01"
- heading "村屋天台太陽能系統" [level=3] [ref=f28e114]
- paragraph [ref=f28e115]: 善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。
- group [ref=f28e119]:
- paragraph [ref=f28e120]: "02"
- heading "合法合規支架工程" [level=3] [ref=f28e121]
- paragraph [ref=f28e122]: 按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。
- group [ref=f28e126]:
- paragraph [ref=f28e127]: "03"
- heading "代辦申請及文件" [level=3] [ref=f28e128]
- paragraph [ref=f28e129]: 由中電/港燈申請、上網電價計劃(FiT)到相關政府文件,全程代辦。
- group [ref=f28e133]:
- paragraph [ref=f28e134]: "04"
- heading "專業安裝及驗收" [level=3] [ref=f28e135]
- paragraph [ref=f28e136]: 由認可人士(AP)及註冊電工施工,簽發所需認證文件,安全合法。
- group [ref=f28e140]:
- paragraph [ref=f28e141]: "05"
- heading "降低頂樓室溫" [level=3] [ref=f28e142]
- paragraph [ref=f28e143]: 太陽能板為天台遮蔭,有效降低頂樓單位溫度,同時慳冷氣電費。
- group [ref=f28e147]:
- paragraph [ref=f28e148]: "06"
- heading "收益及回本分析" [level=3] [ref=f28e149]
- paragraph [ref=f28e150]: 按日照、面積同電價,提供清晰嘅發電量同回本估算,一目了然。
- generic [ref=f28e155]:
- generic [ref=f28e156]:
- paragraph [ref=f28e159]: 點解揀我哋
- heading "結合專業工程同貼心溝通" [level=2] [ref=f28e160]
- paragraph [ref=f28e161]: 我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。
- generic [ref=f28e162]:
- generic [ref=f28e163]:
- paragraph [ref=f28e164]: "01"
- heading "熟悉香港村屋環境" [level=3] [ref=f28e165]
- paragraph [ref=f28e166]: 深耕本地市場,充分掌握村屋結構、法規同地理限制。
- generic [ref=f28e167]:
- paragraph [ref=f28e168]: "02"
- heading "流程清晰易明" [level=3] [ref=f28e169]
- paragraph [ref=f28e170]: 用最簡單直白嘅方式解說回本期同技術細節,絕無花巧術語。
- generic [ref=f28e171]:
- paragraph [ref=f28e172]: "03"
- heading "專人快速跟進" [level=3] [ref=f28e173]
- paragraph [ref=f28e174]: 一對一顧問服務,由評估到完工全程透明,隨時解答疑問。
- generic [ref=f28e175]:
- paragraph [ref=f28e176]: "04"
- heading "美觀與效率兼備" [level=3] [ref=f28e177]
- paragraph [ref=f28e178]: 追求發電效率之餘,極致重視施工整齊度同建築美感。
- generic [ref=f28e179]:
- paragraph [ref=f28e180]: "05"
- heading "重視施工安全" [level=3] [ref=f28e181]
- paragraph [ref=f28e182]: 嚴選耐用器材、嚴格執行安全程序,確保系統長久穩定。
- generic [ref=f28e183]:
- paragraph [ref=f28e184]: "06"
- heading "不硬銷真誠建議" [level=3] [ref=f28e185]
- paragraph [ref=f28e186]: 先幫你了解安裝可行性,未必即刻做,最緊要係問清楚。
- generic [ref=f28e188]:
- generic [ref=f28e189]:
- paragraph [ref=f28e192]: 服務流程
- heading "專業安裝流程 5 步到位" [level=2] [ref=f28e193]
- paragraph [ref=f28e194]: 清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。
- generic [ref=f28e196]:
- generic [ref=f28e197]:
- generic [ref=f28e198]: "01"
- paragraph [ref=f28e199]: 初步諮詢 / 現場評估
- paragraph [ref=f28e200]: 專人上門視察環境,提供專業技術建議。
- generic [ref=f28e201]:
- generic [ref=f28e202]: "02"
- paragraph [ref=f28e203]: 申請文件處理
- paragraph [ref=f28e204]: 代辦電力公司同政府部門所需嘅申請文件。
- generic [ref=f28e205]:
- generic [ref=f28e206]: "03"
- paragraph [ref=f28e207]: 安裝施工
- paragraph [ref=f28e208]: 專業團隊進場施工,確保工期同施工品質安全。
- generic [ref=f28e209]:
- generic [ref=f28e210]: "04"
- paragraph [ref=f28e211]: 驗收認證
- paragraph [ref=f28e212]: 由認可人士及合資格電工簽發 C/PVS、WR1、GF1 等文件。
- generic [ref=f28e213]:
- generic [ref=f28e214]: "05"
- paragraph [ref=f28e215]: 掛表正式發電
- paragraph [ref=f28e216]: 電力公司掛上電表後,正式開始享受太陽能發電收益。
- generic [ref=f28e218]:
- generic [ref=f28e219]:
- paragraph [ref=f28e222]: 完成案例
- heading "真實村屋工程案例" [level=2] [ref=f28e223]
- paragraph [ref=f28e224]: 我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。
- generic [ref=f28e225]:
- group [ref=f28e226]:
- img "大埔上碗窯 — 雙玻光伏板 航空鋁支架" [ref=f28e228]
- generic [ref=f28e229]:
- generic [ref=f28e230]:
- paragraph [ref=f28e231]: 2026年2月
- paragraph [ref=f28e236]: 大埔半山
- heading "大埔上碗窯 — 雙玻光伏板 航空鋁支架" [level=3] [ref=f28e237]
- paragraph [ref=f28e238]: 採用雙玻光伏板配航空級鋁合金支架,兼顧發電效率同天台景觀。
- generic [ref=f28e239]:
- group [ref=f28e240]:
- img "元朗山下村 — 香檳色鋁合金支架" [ref=f28e242]
- generic [ref=f28e243]:
- paragraph [ref=f28e244]: 2025年7月
- paragraph [ref=f28e249]: 元朗屏山
- heading "元朗山下村 — 香檳色鋁合金支架" [level=3] [ref=f28e250]
- paragraph [ref=f28e251]: 香檳色支架配合村屋外牆色調,施工整齊,發電穩定。
- group [ref=f28e252]:
- img "西貢西澳村 — 焗白色鋁合金支架" [ref=f28e254]
- generic [ref=f28e255]:
- paragraph [ref=f28e256]: 2025年5月
- paragraph [ref=f28e261]: 新界西貢
- heading "西貢西澳村 — 焗白色鋁合金支架" [level=3] [ref=f28e262]
- paragraph [ref=f28e263]: 焗白色支架低調美觀,善用天台每一寸可用空間。
- generic [ref=f28e265]:
- generic [ref=f28e266]:
- paragraph [ref=f28e269]: 常見問題
- heading "安裝前,你可能想知嘅事" [level=2] [ref=f28e270]
- paragraph [ref=f28e271]: 仲有其他疑問?歡迎隨時 WhatsApp 問我哋。
- generic [ref=f28e273]:
- generic [ref=f28e274]:
- button [expanded] [ref=f28e275]:
- paragraph [ref=f28e276]: 村屋天台可以安裝太陽能嗎?
- region "村屋天台可以安裝太陽能嗎?" [ref=f28e281]:
- generic [ref=f28e282]: 大部分村屋天台都可以。我哋會先上門評估結構、日照同遮擋情況,再判斷可行性同建議方案。
- button [ref=f28e284]:
- paragraph [ref=f28e285]: 安裝太陽能大概幾錢?幾時回本?
- button [ref=f28e289]:
- paragraph [ref=f28e290]: 需要幾大天台面積?
- button [ref=f28e294]:
- paragraph [ref=f28e295]: 安裝太陽能合唔合法?會唔會影響樓宇結構?
- button [ref=f28e299]:
- paragraph [ref=f28e300]: 系統需要保養嗎?
- button [ref=f28e304]:
- paragraph [ref=f28e305]: 申請上網電價(FiT)麻煩嗎?
- generic [ref=f28e310]:
- generic [ref=f28e311]:
- paragraph [ref=f28e314]: 免費評估
- heading "立即預約免費評估" [level=2] [ref=f28e315]
- paragraph [ref=f28e316]: 想知你嘅村屋天台啱唔啱裝太陽能?歡迎 WhatsApp 或致電,我哋會用清晰易明嘅方式為你詳答。
- generic [ref=f28e317]:
- link "WhatsApp 查詢" [ref=f28e318] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "致電我哋" [ref=f28e321] [cursor=pointer]:
- /url: tel:+85200000000
- generic [ref=f28e324]:
- paragraph [ref=f28e329]: +852 0000 0000
- paragraph [ref=f28e335]: [email protected]
- paragraph [ref=f28e341]: 香港新界(地址待填)
- contentinfo [ref=f28e342]:
- generic [ref=f28e343]:
- generic [ref=f28e344]:
- generic [ref=f28e345]:
- paragraph [ref=f28e346]: 盈豐太陽能
- paragraph [ref=f28e347]: 專營香港村屋太陽能系統,由評估、設計、申請到安裝驗收,一站式為你善用天台,賺取發電回報。
- paragraph [ref=f28e348]: 電業承辦商編號:待填
- generic [ref=f28e349]:
- generic [ref=f28e350]:
- paragraph [ref=f28e351]: 快速連結
- link "服務範圍" [ref=f28e352] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f28e353] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f28e354] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f28e355] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f28e356] [cursor=pointer]:
- /url: /blog
- generic [ref=f28e357]:
- paragraph [ref=f28e358]: 聯絡我哋
- link "+852 0000 0000" [ref=f28e362] [cursor=pointer]:
- /url: tel:+85200000000
- link "WhatsApp" [ref=f28e366] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- link "[email protected]" [ref=f28e371] [cursor=pointer]:
- /url: mailto:[email protected]?subject=%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D
- paragraph [ref=f28e376]: 香港新界(地址待填)
- generic [ref=f28e378]:
- paragraph [ref=f28e379]: © 2026 盈豐太陽能工程有限公司 All rights reserved.
- link "管理後台" [ref=f28e380] [cursor=pointer]:
- /url: /admin
- link "WhatsApp 查詢" [ref=f28e381] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- generic [ref=f28e386]:
- button [ref=f28e387]
- button [ref=f28e393]
- button [ref=f28e397]
- button [ref=f28e402]
@@ -0,0 +1,312 @@
- generic:
- generic [aria-hidden]:
- banner:
- generic:
- generic:
- link:
- /url: /
- paragraph: 盈豐太陽能
- generic:
- button [expanded]
- main:
- generic:
- generic:
- generic:
- generic:
- generic:
- paragraph: 香港村屋太陽能專家
- heading [level=1]: 善用村屋天台 發電慳電賺回報
- paragraph: 由現場評估、方案建議、安裝施工,到申請同跟進,我哋用清晰易明嘅方式,陪你完成整個村屋太陽能項目。
- generic:
- link:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- text: 免費預約評估
- link:
- /url: "#cases"
- text: 睇完成案例
- generic:
- generic:
- paragraph: 註冊電業承辦商
- generic:
- paragraph: 舊客口碑介紹
- generic:
- paragraph: 一對一專人跟進
- generic:
- generic:
- generic:
- paragraph: 一般回本期
- paragraph: 約 69 年
- generic:
- paragraph: 系統可用年期
- paragraph: 2025 年
- generic:
- paragraph: 牌照
- paragraph: 電業承辦商編號:待填
- generic:
- generic:
- generic:
- generic:
- generic:
- paragraph: 註冊電業承辦商
- paragraph: 合資格承辦,安全合法
- generic:
- generic:
- paragraph: 上網電價 FiT 代辦
- paragraph: 中電/港燈申請全程跟進
- generic:
- generic:
- paragraph: 認可人士 AP 簽發
- paragraph: C/PVS、WR1、GF1 文件
- generic:
- generic:
- paragraph: 2025 年系統壽命
- paragraph: 優質器材,長遠穩定
- generic:
- generic:
- generic:
- generic:
- paragraph: 服務範圍
- heading [level=2]: 專為村屋而設嘅太陽能方案
- paragraph: 由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。
- generic:
- group:
- paragraph: "01"
- heading [level=3]: 村屋天台太陽能系統
- paragraph: 善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。
- group:
- paragraph: "02"
- heading [level=3]: 合法合規支架工程
- paragraph: 按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。
- group:
- paragraph: "03"
- heading [level=3]: 代辦申請及文件
- paragraph: 由中電/港燈申請、上網電價計劃(FiT)到相關政府文件,全程代辦。
- group:
- paragraph: "04"
- heading [level=3]: 專業安裝及驗收
- paragraph: 由認可人士(AP)及註冊電工施工,簽發所需認證文件,安全合法。
- group:
- paragraph: "05"
- heading [level=3]: 降低頂樓室溫
- paragraph: 太陽能板為天台遮蔭,有效降低頂樓單位溫度,同時慳冷氣電費。
- group:
- paragraph: "06"
- heading [level=3]: 收益及回本分析
- paragraph: 按日照、面積同電價,提供清晰嘅發電量同回本估算,一目了然。
- generic:
- generic:
- generic:
- generic:
- paragraph: 點解揀我哋
- heading [level=2]: 結合專業工程同貼心溝通
- paragraph: 我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。
- generic:
- generic:
- paragraph: "01"
- heading [level=3]: 熟悉香港村屋環境
- paragraph: 深耕本地市場,充分掌握村屋結構、法規同地理限制。
- generic:
- paragraph: "02"
- heading [level=3]: 流程清晰易明
- paragraph: 用最簡單直白嘅方式解說回本期同技術細節,絕無花巧術語。
- generic:
- paragraph: "03"
- heading [level=3]: 專人快速跟進
- paragraph: 一對一顧問服務,由評估到完工全程透明,隨時解答疑問。
- generic:
- paragraph: "04"
- heading [level=3]: 美觀與效率兼備
- paragraph: 追求發電效率之餘,極致重視施工整齊度同建築美感。
- generic:
- paragraph: "05"
- heading [level=3]: 重視施工安全
- paragraph: 嚴選耐用器材、嚴格執行安全程序,確保系統長久穩定。
- generic:
- paragraph: "06"
- heading [level=3]: 不硬銷真誠建議
- paragraph: 先幫你了解安裝可行性,未必即刻做,最緊要係問清楚。
- generic:
- generic:
- generic:
- generic:
- paragraph: 服務流程
- heading [level=2]: 專業安裝流程 5 步到位
- paragraph: 清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。
- generic:
- generic:
- generic:
- generic: "01"
- paragraph: 初步諮詢 / 現場評估
- paragraph: 專人上門視察環境,提供專業技術建議。
- generic:
- generic: "02"
- paragraph: 申請文件處理
- paragraph: 代辦電力公司同政府部門所需嘅申請文件。
- generic:
- generic: "03"
- paragraph: 安裝施工
- paragraph: 專業團隊進場施工,確保工期同施工品質安全。
- generic:
- generic: "04"
- paragraph: 驗收認證
- paragraph: 由認可人士及合資格電工簽發 C/PVS、WR1、GF1 等文件。
- generic:
- generic: "05"
- paragraph: 掛表正式發電
- paragraph: 電力公司掛上電表後,正式開始享受太陽能發電收益。
- generic:
- generic:
- generic:
- generic:
- paragraph: 完成案例
- heading [level=2]: 真實村屋工程案例
- paragraph: 我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。
- generic:
- group:
- generic:
- generic:
- paragraph: 2026年2月
- generic:
- paragraph: 大埔半山
- heading [level=3]: 大埔上碗窯 — 雙玻光伏板 航空鋁支架
- paragraph: 採用雙玻光伏板配航空級鋁合金支架,兼顧發電效率同天台景觀。
- generic:
- group:
- generic:
- paragraph: 2025年7月
- generic:
- paragraph: 元朗屏山
- heading [level=3]: 元朗山下村 — 香檳色鋁合金支架
- paragraph: 香檳色支架配合村屋外牆色調,施工整齊,發電穩定。
- group:
- generic:
- paragraph: 2025年5月
- generic:
- paragraph: 新界西貢
- heading [level=3]: 西貢西澳村 — 焗白色鋁合金支架
- paragraph: 焗白色支架低調美觀,善用天台每一寸可用空間。
- generic:
- generic:
- generic:
- generic:
- paragraph: 常見問題
- heading [level=2]: 安裝前,你可能想知嘅事
- paragraph: 仲有其他疑問?歡迎隨時 WhatsApp 問我哋。
- generic:
- generic:
- generic:
- button [expanded]:
- paragraph: 村屋天台可以安裝太陽能嗎?
- region:
- generic: 大部分村屋天台都可以。我哋會先上門評估結構、日照同遮擋情況,再判斷可行性同建議方案。
- generic:
- button:
- paragraph: 安裝太陽能大概幾錢?幾時回本?
- generic:
- button:
- paragraph: 需要幾大天台面積?
- generic:
- button:
- paragraph: 安裝太陽能合唔合法?會唔會影響樓宇結構?
- generic:
- button:
- paragraph: 系統需要保養嗎?
- generic:
- button:
- paragraph: 申請上網電價(FiT)麻煩嗎?
- generic:
- generic:
- generic:
- generic:
- generic:
- paragraph: 免費評估
- heading [level=2]: 立即預約免費評估
- paragraph: 想知你嘅村屋天台啱唔啱裝太陽能?歡迎 WhatsApp 或致電,我哋會用清晰易明嘅方式為你詳答。
- generic:
- link:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- text: WhatsApp 查詢
- link:
- /url: tel:+85200000000
- text: 致電我哋
- generic:
- generic:
- paragraph: +852 0000 0000
- generic:
- paragraph: [email protected]
- generic:
- paragraph: 香港新界(地址待填)
- contentinfo:
- generic:
- generic:
- generic:
- paragraph: 盈豐太陽能
- paragraph: 專營香港村屋太陽能系統,由評估、設計、申請到安裝驗收,一站式為你善用天台,賺取發電回報。
- paragraph: 電業承辦商編號:待填
- generic:
- generic:
- paragraph: 快速連結
- link:
- /url: "#services"
- text: 服務範圍
- link:
- /url: "#cases"
- text: 完成案例
- link:
- /url: "#process"
- text: 服務流程
- link:
- /url: "#faq"
- text: 常見問題
- link:
- /url: /blog
- text: Blog
- generic:
- paragraph: 聯絡我哋
- generic:
- link:
- /url: tel:+85200000000
- text: +852 0000 0000
- generic:
- link:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- text: WhatsApp
- generic:
- link:
- /url: mailto:[email protected]?subject=%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D
- text: [email protected]
- generic:
- paragraph: 香港新界(地址待填)
- generic:
- generic:
- paragraph: © 2026 盈豐太陽能工程有限公司 All rights reserved.
- link:
- /url: /admin
- text: 管理後台
- link:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- generic [ref=f28e386]:
- button [ref=f28e407]:
- generic: Menu
- button [ref=f28e408]:
- generic: Inspect
- button [ref=f28e409]:
- generic: Audit
- button [ref=f28e413]:
- generic: Settings
- dialog [ref=f28e414]:
- heading "盈豐太陽能" [level=2] [ref=f28e416]
- generic [ref=f28e418]:
- link "服務" [active] [ref=f28e419] [cursor=pointer]:
- /url: "#services"
- link "完成案例" [ref=f28e420] [cursor=pointer]:
- /url: "#cases"
- link "服務流程" [ref=f28e421] [cursor=pointer]:
- /url: "#process"
- link "常見問題" [ref=f28e422] [cursor=pointer]:
- /url: "#faq"
- link "Blog" [ref=f28e423] [cursor=pointer]:
- /url: /blog
- link "WhatsApp 免費諮詢" [ref=f28e425] [cursor=pointer]:
- /url: https://api.whatsapp.com/send?phone=85200000000&text=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%88%91%E6%83%B3%E6%9F%A5%E8%A9%A2%E6%9D%91%E5%B1%8B%E5%A4%AA%E9%99%BD%E8%83%BD%E5%AE%89%E8%A3%9D%EF%BC%8C%E6%83%B3%E9%A0%90%E7%B4%84%E5%85%8D%E8%B2%BB%E8%A9%95%E4%BC%B0%E3%80%82
- button "Close" [ref=f28e428] [cursor=pointer]
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+121
View File
@@ -0,0 +1,121 @@
# AGENTS.md
Astro 7 + React 19 + Chakra UI v3,部署在 Cloudflare Workers(單一 Worker+ D1 + Drizzle。一頁式官網 + Blog + `/admin` 後台。
## 指令
- `npm run dev` — Astro dev:4321,經 wrangler platformProxy 讀 `wrangler.jsonc` bindings
- `npm run build``astro build`(唯一的驗證步驟)
- `npm run preview` / `npm run deploy` — build 後用 wrangler dev / deploy
- DB(本機用 `:local`,雲端省略):`npm run db:generate``db:migrate:local` / `db:migrate``db:seed:local` / `db:seed`
- `npm run types` — 重新產生 `worker-configuration.d.ts``npm run secret` — 設定 `ADMIN_PASSWORD`
- 沒有 test / lint / typecheck script(未安裝 @astrojs/check)。`npm run build` 是唯一可跑的驗證。
## 關鍵慣例與陷阱
- **SSR 頁面必須自己聲明**`astro.config.mjs``output: "static"`,所有要讀 D1 的動態頁面都靠檔案內 `export const prerender = false`(首頁、blog、admin、sitemap/robots)。新增需要 request-time 資料的頁面時一定要加。
- **環境變數只用 `getEnv()`**`src/lib/env.ts`,底層 `cloudflare:workers``env`)。Astro v6 起已移除 `Astro.locals.runtime.env``getEnv()` 只能在 `prerender = false` 的頁面/endpoint 用。
- **資料層**`getDb(getEnv().DB)` → Drizzle;查詢集中在 `src/data/content.ts`schema 在 `src/db/schema.ts`。改 schema 後跑 `npm run db:generate` 產生 migration。
- **Seed 與 migration 是分開的**README 講法有誤):`db:migrate:local` 只套 migrations`migrations/seed.sql` 要用 `db:seed:local` 另外執行;seed 可重複跑。
- **`wrangler.jsonc` 有佔位 id**`database_id` / KV `id` 要先用 `db:create` / `kv:create` 產生再貼上,未貼前無法部署。
- **上線前要改 `astro.config.mjs``site`**sitemap / canonical / OG 全部靠它(現為 `https://example.com`)。
- **後台認證**`src/middleware.ts` 保護所有 `/admin`,用 HMAC-signed cookie`src/lib/auth.ts`);未設 `ADMIN_PASSWORD`(本機在 `.dev.vars`,已 gitignore)會回 503。
- **Chakra v3 + Emotion**:前台 React 元件要用 `Provider` / `SiteChrome`(內含 `<ChakraProvider value={system}>`),theme 在 `src/theme/system.ts`。Astro 以 `client:load` 掛 island。
- **語言**:UI 文案、註解、後台全部都係繁體中文(廣東話)——新增內容請保持一致;網站只做中文,不做雙語。
- `dist/``.astro/``.wrangler/``worker-configuration.d.ts` 都係產生檔,唔好手改。
- `docs/superpowers/` 有設計規格與實作計劃,係設計決策的權威來源。
# DOX framework
- DOX is highly performant AGENTS.md hierarchy installed here
- Agent must follow DOX instructions across any edits
## Core Contract
- AGENTS.md files are binding work contracts for their subtrees
- Work products, source materials, instructions, records, assets, and durable docs must stay understandable from the nearest applicable AGENTS.md plus every parent AGENTS.md above it
## Read Before Editing
1. Read the root AGENTS.md
2. Identify every file or folder you expect to touch
3. Walk from the repository root to each target path
4. Read every AGENTS.md found along each route
5. If a parent AGENTS.md lists a child AGENTS.md whose scope contains the path, read that child and continue from there
6. Use the nearest AGENTS.md as the local contract and parent docs for repo-wide rules
7. If docs conflict, the closer doc controls local work details, but no child doc may weaken DOX
Do not rely on memory. Re-read the applicable DOX chain in the current session before editing.
## Update After Editing
Every meaningful change requires a DOX pass before the task is done.
Update the closest owning AGENTS.md when a change affects:
- purpose, scope, ownership, or responsibilities
- durable structure, contracts, workflows, or operating rules
- required inputs, outputs, permissions, constraints, side effects, or artifacts
- user preferences about behavior, communication, process, organization, or quality
- AGENTS.md creation, deletion, move, rename, or index contents
Update parent docs when parent-level structure, ownership, workflow, or child index changes. Update child docs when parent changes alter local rules. Remove stale or contradictory text immediately. Small edits that do not change behavior or contracts may leave docs unchanged, but the DOX pass still must happen.
## Hierarchy
- Root AGENTS.md is the DOX rail: project-wide instructions, global preferences, durable workflow rules, and the top-level Child DOX Index
- Child AGENTS.md files own domain-specific instructions and their own Child DOX Index
- Each parent explains what its direct children cover and what stays owned by the parent
- The closer a doc is to the work, the more specific and practical it must be
## Child Doc Shape
- Create a child AGENTS.md when a folder becomes a durable boundary with its own purpose, rules, responsibilities, workflow, materials, or quality standards
- Work Guidance must reflect the current standards of the project or user instructions; if there are no specific standards or instructions yet, leave it empty
- Verification must reflect an existing check; if no verification framework exists yet, leave it empty and update it when one exists
Default section order:
- Purpose
- Ownership
- Local Contracts
- Work Guidance
- Verification
- Child DOX Index
## Style
- Keep docs concise, current, and operational
- Document stable contracts, not diary entries
- Put broad rules in parent docs and concrete details in child docs
- Prefer direct bullets with explicit names
- Do not duplicate rules across many files unless each scope needs a local version
- Delete stale notes instead of explaining history
- Trim obvious statements, repeated rules, misplaced detail, and warnings for risks that no longer exist
## Closeout
1. Re-check changed paths against the DOX chain
2. Update nearest owning docs and any affected parents or children
3. Refresh every affected Child DOX Index
4. Remove stale or contradictory text
5. Run existing verification when relevant
6. Report any docs intentionally left unchanged and why
## User Preferences
When the user requests a durable behavior change, record it here or in the relevant child AGENTS.md
## Child DOX Index
| Path | Scope |
|---|---|
| `src/AGENTS.md` | 原始碼全層:架構、跨層慣例;擁有 `theme/``layouts/``config.ts``middleware.ts``env.d.ts`,以及 `pages/` 路由與 `/admin` 後台合約 |
| `src/components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `src/data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `src/db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `src/lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
| `migrations/AGENTS.md` | D1 migration 與 seed SQL |
| `docs/AGENTS.md` | 設計規格與實作計劃(`superpowers/` 為設計權威) |
Project-level files with no child doc are owned by this root: `astro.config.mjs``wrangler.jsonc``drizzle.config.ts``tsconfig.json``package.json``README.md``.dev.vars.example`
+103
View File
@@ -0,0 +1,103 @@
# 盈豐太陽能工程有限公司 — 官網(村屋太陽能)
**Astro 7 + React 19 + Chakra UI v3 + Cloudflare Workers + D1 + Drizzle。全部部署喺 Cloudflare,零維運。**
一頁式官網(村屋太陽能)+ Blog + 內容管理後台。
## 功能
- **一頁式官網** `/`(SSR,讀 D1):Hero、服務、特色、流程、完成案例、FAQ、聯絡 CTA
- **Blog** `/blog``/blog/[slug]`:封面卡片列表 + Markdown 文章頁
- **後台** `/admin`(密碼登入):管理文章、首頁內容(服務/特色/流程/FAQ)、完成案例、網站設定(公司資料/聯絡/Hero/SEO)
- **SEO**canonical、OG/Twitter card、`/sitemap.xml``/robots.txt`、結構化語意 HTML
- 首頁/Blog 用邊緣快取(`s-maxage`),後台改完約 1 分鐘內生效
## 技術棧
| 層 | 選用 |
|---|---|
| 前端框架 | Astro 7`output: "static"` + per-page SSR |
| UI | React 19 + `@chakra-ui/react` v3 + `@emotion/react`(清新天藍/綠色系 theme |
| Adapter | `@astrojs/cloudflare`(一個 Worker |
| DB | Cloudflare D1SQLite |
| ORM | Drizzle |
| Auth | HMAC-signed cookie`src/lib/auth.ts` |
| Markdown | `marked` |
| Icons | `lucide-react` |
> Chakra v3 用 EmotionSSR 會輸出 inline critical CSS(首屏即有樣式、零 FOUC),client hydrate 後整合到 `<head>`;已實測 Astro 7 + React 19 無 hydration error。
## 本機開發
```bash
npm install
# 建本機 D1 + 套 migrationmigration 會連 seed.sql 一齊套)
npm run db:migrate:local
# 重新匯入種子資料(可重複執行)
npm run db:seed:local
# 開發 server:4321
npx astro dev
```
本機後台密碼喺 `.dev.vars``ADMIN_PASSWORD=changeme-local`,已 gitignore)。
想模擬 productionwrangler):`npx wrangler dev`
## 部署到 Cloudflare
```bash
npm run login # 瀏覽器授權
npm run db:create # 印 database_id → 填入 wrangler.jsonc
npm run kv:create # 印 namespace id → 填入 wrangler.jsonc
npm run db:migrate # 雲端套 migration
npm run db:seed # 雲端匯入種子(公司資料 + 樣本內容)
npx wrangler secret put ADMIN_PASSWORD # 設定後台密碼
# 改 astro.config.mjs 嘅 site = 真網域
npm run deploy
```
之後更新:`npm run deploy`
## 後台點用
1.`/admin`,輸入密碼登入
2. **內容總覽** → 服務 / 特色 / 流程 / 常見問題 / 完成案例 / 網站設定
3. 每個項目可新增、修改、刪除、用 ↑↓ 排序、「已發布 / 草稿」控制前台顯示
4. 圖片:直接貼圖片 URL(暫時;之後可換成上傳)
## 結構
```
src/
├─ theme/system.ts Chakra themetokens / 顏色 / 字體)
├─ components/site/*.tsx 前台 React 元件(Header/Hero/Services/Faq…)
├─ data/
│ ├─ content.ts D1 讀取 + WhatsApp/電話 link helper
│ └─ settings-fields.ts 後台設定欄位定義
├─ db/schema.ts Drizzle schemaposts / site_settings / content_items / cases
├─ layouts/Base.astro SEO head + fonts
├─ layouts/AdminLayout.astro 後台外框
├─ pages/
│ ├─ index.astro SSR 首頁
│ ├─ blog/… Blog
│ ├─ admin/… 後台
│ └─ sitemap.xml.ts / robots.txt.ts
└─ lib/{auth,db,env,markdown}.ts
migrations/
├─ 0000_init.sql … Drizzle migrations
└─ seed.sql 種子資料(與 migration 一齊執行)
```
## 待客戶提供(可喺 `/admin/settings` 自行更新)
- 真實電話 / WhatsApp / Email / 地址
- 電業承辦商牌照號碼
- 真域名(`astro.config.mjs``site`
- 真實工程相片(現時用佔位圖)
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
export default defineConfig({
integrations: [react()],
// ⚠️ 上線前改咗呢個做你嘅真域名(sitemap、canonical、OG 全部靠佢)
site: "https://example.com",
output: "static",
adapter: cloudflare({
platformProxy: {
enabled: true,
// 本機 astro dev 時讀 wrangler.jsonc 嘅 bindings
configPath: "./wrangler.jsonc",
},
}),
});
+29
View File
@@ -0,0 +1,29 @@
# docs/AGENTS.md
## Purpose
專案的設計規格與實作計劃文件。
## Ownership
- 擁有 `docs/` 全樹,包括 `docs/superpowers/specs/``docs/superpowers/plans/`
## Local Contracts
- **設計權威**`superpowers/specs/` 係已確認的設計規格,係設計決策的權威來源。實作與 spec 有出入時,以 spec 為準,或先更新 spec。
- **實作計劃**`superpowers/plans/` 用 checkbox 追蹤進度;計劃與實際實作有偏差時同步更新。
- **檔名**`YYYY-MM-DD-<slug>.md`
- 內容繁體中文(廣東話)。
## Work Guidance
- 做大改動前先讀對應 spec / plan;完成後如設計有變,更新文件而唔係留低過時描述。
- 呢啲係設計記錄,唔係日常 diary;只記穩定決策同計劃。
## Verification
無自動化檢查;靠人工對照 spec 同實作。
## Child DOX Index
無(`superpowers/` 由本文件覆蓋)。
@@ -0,0 +1,180 @@
# 盈豐太陽能官網 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 將現有 Astro + Cloudflare + D1 骨幹改造成盈豐太陽能工程有限公司的村屋太陽能一頁式官網(Chakra UI)+ Blog + 內容管理後台。
**Architecture:** Astro 7`output: "static"` + per-page `prerender=false`)作全疊框架,`@astrojs/react` 提供 React islandChakra UI v3Emotion)負責前台 UI。首頁 SSR 讀 D1,經 `Cache-Control: s-maxage` 邊緣快取。後台沿用 Astro SSR 表單 + HMAC cookie。資料層用 Drizzle + D1。
**Tech Stack:** Astro 7.3 / @astrojs/react 6 / React 19 / @chakra-ui/react 3.37 / @emotion/react / Drizzle ORM / Cloudflare D1 + KV / marked。
**已驗證(spike):** Chakra v3 + Astro SSR 可正常運作,inline critical CSS、零 FOUC、無 hydration error。
---
## 檔案結構
```
src/
├─ theme/system.ts # Chakra systemtokens/semanticTokens/recipes/fonts
├─ components/site/
│ ├─ Provider.tsx # <ChakraProvider value={system}>
│ ├─ SiteChrome.tsx # Header + main + Footer 外框
│ ├─ Header.tsx Hero.tsx TrustBar.tsx Services.tsx Features.tsx
│ ├─ Process.tsx Cases.tsx Faq.tsx ContactCta.tsx Footer.tsx
│ ├─ WhatsAppFab.tsx Section.tsx SectionHeading.tsx
│ ├─ HomePage.tsx # 組合首頁所有 section
│ ├─ BlogIndex.tsx BlogPost.tsx
├─ data/content.ts # D1 讀取:settings/items/cases,型別化
├─ db/schema.ts # posts + site_settings + content_items + cases
├─ db/seed.ts # 種子公司資料 + 樣本內容
├─ layouts/Base.astro # SEO head / fonts / global reset
├─ layouts/AdminLayout.astro # 後台外框(沿用 + 換色)
├─ pages/index.astro # SSR 讀 D1 → <HomePage client:load>
├─ pages/blog/index.astro # SSR → <BlogIndex client:load>
├─ pages/blog/[slug].astro # SSR → <BlogPost client:load :html>
├─ pages/admin/** # login/logout/index/post/settings/content/cases
├─ pages/sitemap.xml.ts robots.txt.ts
└─ lib/{auth,db,env,markdown}.ts(沿用)
```
---
## Phase 1 — Foundation
### Task 1: Chakra theme system
**Files:** Create `src/theme/system.ts`
- [ ] 定義 tokens`colors.brand`#0B8A5E 色階)、`colors.sky`#0BA5EC 色階)、`fonts.heading/body = Noto Sans HK`
- [ ] 定義 semanticTokens`bg`, `bg.subtle`, `fg`, `fg.muted`, `border`, `brand.solid`, `brand.contrast`, `brand.muted`
- [ ] `createSystem(defaultConfig, config)` 匯出 `system`
- [ ] 驗證:`npm run build` 無 TypeScript/Chakra token 錯誤
### Task 2: Provider + SiteChrome
**Files:** Create `src/components/site/Provider.tsx`, `src/components/site/SiteChrome.tsx`, `Section.tsx`, `SectionHeading.tsx`
- [ ] `Provider` = `<ChakraProvider value={system}>{children}</ChakraProvider>`
- [ ] `SiteChrome` = `<Provider><Header/><Box as="main">{children}</Box><Footer/><WhatsAppFab/></Provider>`
- [ ] 驗證:build 過
### Task 3: Base.astro SEO layout
**Files:** Modify `src/layouts/Base.astro`
- [ ] 保留 canonical / OG / Twitter / robots 邏輯;加 `og:image`cover prop
- [ ] 加 Google FontsNoto Sans HK`<link>`,加中文字體 fallback
- [ ] 移除舊的純 CSS 樣式(改由 Chakra 負責),只保留最小 global reset(可在 Chakra `globalCss`
- [ ] 驗證:build 過
---
## Phase 2 — Data layer
### Task 4: Drizzle schema
**Files:** Modify `src/db/schema.ts`
- [ ] `posts``cover_image` (text), `tags` (text)
- [ ] 新增 `site_settings`key PK, value, updated_at
- [ ] 新增 `content_items`id, kind, title, description, extra, sort_order, status, updated_at+ index(kind,status,sort_order)
- [ ] 新增 `cases`id, title, location, completed_at, description, image_url, sort_order, status, updated_at+ index(status,sort_order)
- [ ] 匯出型別
- [ ] 驗證:`npm run db:generate` 產生 migration
### Task 5: Seed
**Files:** Create `src/db/seed.ts`(以 drizzle 插入種子,可 idempotent
- [ ] 公司資料:盈豐太陽能工程有限公司(電話/WhatsApp/email/地址/牌照號用佔位)
- [ ] Hero 文案、SEO 預設
- [ ] services(村屋太陽能、天台空間、合法支架、代辦申請、降室溫、回報計算)
- [ ] features(熟悉香港環境、流程清晰、專人跟進、美觀效率、施工安全、真誠建議)
- [ ] steps(初步諮詢/現場評估 → 申請文件 → 安裝施工 → 驗收認證 → 掛表發電)
- [ ] faqs(村屋天台可否安裝、回本期、需要幾大面積、是否合法、保養、申請邊個)
- [ ] cases(大埔上碗窯、元朗山下村、西貢西澳村 — 佔位圖)
- [ ] 驗證:`npm run db:migrate:local` 後 SQL 查得到
### Task 6: Content access helpers
**Files:** Create `src/data/content.ts`
- [ ] `getSettings(db)` → Record<string,string>
- [ ] `getItems(db, kind)` → published, order by sort_order
- [ ] `getCases(db)` → published, order by sort_order
- [ ] `getPublishedPosts(db)` / `getPostBySlug(db, slug)`
- [ ] 型別 `Settings`, `ContentItem`, `CaseItem`
- [ ] 驗證:TS build 過
---
## Phase 3 — Homepage UI
### Task 716: Sections
**Files:** Create 各 section 元件(見檔案結構)
每個 section 一個 task,包含:完整 Chakra markup、響應式、內容 props、設計(清新天藍/綠)。
- [ ] Headersticky、玻璃感、手機 drawer、錨點導覽、WhatsApp CTA
- [ ] Hero(眉題/標題/副標/雙 CTA/主視覺圖/信任子彈)
- [ ] TrustBar(牌照號、案例數、口碑)
- [ ] Services(卡片 grid
- [ ] Features6 卡)
- [ ] Process5 步 timeline
- [ ] Cases(案例卡 + 圖)
- [ ] FaqAccordion
- [ ] ContactCtaWhatsApp/電話/email/地址)
- [ ] Footer + WhatsAppFab
### Task 17: HomePage 組裝 + index.astro
- [ ] `HomePage.tsx` 接收 `{ settings, services, features, steps, faqs, cases }` props,用 `<SiteChrome>` 包住所有 section
- [ ] `src/pages/index.astro``prerender=false`,讀 D1`<HomePage ... client:load />`,設 `Cache-Control`
- [ ] 驗證:`astro dev` 開 / 無 console errorRWD 檢查
---
## Phase 4 — Blog
### Task 18: Blog UI
- [ ] `BlogIndex.tsx`(卡片:封面/標題/摘要/日期,空狀態)
- [ ] `BlogPost.tsx`(封面/標題/日期/prose/返回)
- [ ]`blog/index.astro``blog/[slug].astro` 用新元件(改 SSR propless → props
- [ ] 驗證:build + 瀏覽器
---
## Phase 5 — Admin
### Task 1922
- [ ] `/admin/settings`:分組表單(公司/聯絡/Hero/SEO),POST upsert
- [ ] `/admin/content/[kind]`:通用 list + 編輯 + 上下排序(service/feature/step/faq
- [ ] `/admin/cases``/admin/cases/[id]`CRUD
- [ ] `/admin/post/[id]`:加封面圖欄位
- [ ] 驗證:登入後可改,前台即刻反映(SSR)
---
## Phase 6 — SEO / Deploy
### Task 2324
- [ ] 更新 `sitemap.xml.ts`(含 /blog 文章 cover)、`robots.txt.ts`
- [ ] `wrangler.jsonc`name=`yingfung-solar``SITE_NAME`=盈豐太陽能、佔位 d1/kv id
- [ ] `astro.config.mjs``site``react()`(已加)
- [ ] `src/config.ts`:公司名/tagline
- [ ] README 更新部署步驟
- [ ] 驗證:`npm run build``npm run preview`、Lighthouse
---
## 驗證指令
- `npm run build` 必須成功
- `npm run db:migrate:local` 建本機 DB
- `NODE_OPTIONS= npx astro dev` 開 :4321
- 瀏覽器:無 console error、RWD、錨點、FAQ accordion、WhatsApp link 正確
- 每個 phase 完成後 commit(如已初始化 git)
@@ -0,0 +1,80 @@
# 盈豐太陽能 — 前台 Editorial 極簡重新設計 (Redesign Spec)
- **日期**2026-09-11
- **狀態**:已與客戶確認方向
- **背景**:初版 UI(見 `2026-09-11-ying-fung-solar-design.md`)被客戶評為「AI 模板感太重」。本規格取代該 spec 的 §5 設計系統部分;其餘架構/資料庫/路由/SEO 決策不變。
---
## 1. 設計方向
**暖米白 × 琥珀金 × 明體標題** 的 editorial 極簡風,似高端工程公司官網。
要消滅嘅「AI 模板痕跡」:
- 綠→天藍漸層(logo 磚、hero 光暈、流程圓圈、CTA 橫額)→ 全站零漸層、零光斑
- 膠囊形按鈕(`rounded-full`)→ 直角或微圓角(≤4px
- Pill badge 眉題 →「琥珀短線+細字 letterspaced 眉題」
- Hero 圖上浮卡 → 圖下髮絲線統計列
- Icon 放淺色圓角磚嘅卡片網格 → 編號式清單/髮絲線格網,無卡片陰影
- 柔和陰影卡片 → 髮絲邊框(hairline)分隔
## 2. 範圍
- **只改前台**`src/theme/system.ts``src/layouts/Base.astro``src/components/site/*.tsx`
- **唔郁**:資料層、D1 schema、路由、admin`/admin` 後台維持現有純 CSS 色系)
## 3. 新 Design Tokens`src/theme/system.ts`
### 色彩
| Token | 值方向 | 用途 |
|---|---|---|
| `bg` 頁面 | 暖米白 `#FAF8F4` | 主背景 |
| `bg.subtle` | 略深米白 `#F3EFE7` | 分段背景 |
| `ink`(深色 surface | 墨黑 `#1A1917` | CTA / Footer / 深色 section |
| `fg` | 墨 `#1A1917` | 正文 |
| `fg.muted` | 暖灰 `#6E6862` | 次要文字 |
| `border.subtle` | 髮絲 `#E7E2D9` | 分隔線、邊框 |
| `brand`(唯一彩色) | 琥珀金階梯,主色 `#C2410C``#D97706` 一帶 | CTA、編號、重點數字 |
- 語意 token 名保留(`brand.*``fg.muted``border.subtle``bg.subtle`)以減少改動;舊 `sky` 色階移除。
- `bg.hero` token 移除(Hero 用主背景)。
### 字體
- 標題:`Noto Serif HK`700/900),serif fallback
- 內文:維持 `Noto Sans HK`
- `Base.astro` 的 Google Fonts link 加入 Noto Serif HK 字重;global body 背景轉米白
### 形狀與動效
- 按鈕:實心墨黑或琥珀金,直角/微圓角(`rounded="sm"``none`),唔再係膠囊
- 卡片:去大圓角去陰影,以髮絲線分隔
- 動效:保留簡單 hover(透明度/位移),唔加新動畫庫
## 4. 各 Section 重構
1. **Header**:去玻璃模糊 → 米白實底+髮絲底線;logo 改純文字 wordmarkserif 公司名,唔再係漸層 icon 磚);CTA 改墨黑直角實心掣;手機 drawer 保留但跟新色
2. **Hero**:serif 超大標題;眉題改琥珀短線+細字;剷走圖上浮卡 → 圖下方髮絲線統計列(回本期/系統年期/牌照);次 CTA 改文字連結「睇完成案例 →」;去背景光斑
3. **TrustBar**:四欄髮絲分隔列,幼線 icon+細字,無底色無框
4. **Services**:icon 卡片 → 編號式清單(01/02/03),serif 標題+列頂髮絲線,hover 出現箭嘴
5. **Features**:白卡 → 髮絲線格網(報紙分欄感),編號+serif 標題
6. **Process**:漸層圓圈 → 超大 serif 琥珀數字+髮絲連線
7. **Cases**:去圓角去陰影;首案例 featured 放大(非對稱);地點 pill badge 改細字標籤
8. **FAQ**:白卡 accordion → 髮絲線裸列,serif 問題
9. **ContactCta**:漸層橫額 → 墨黑實底 section,serif 白標題+琥珀實心掣
10. **Footer**:深綠底 → 墨黑底;logo 磚改文字 wordmark;髮絲分隔線
11. **WhatsAppFab**:綠色圓掣 → 墨黑圓掣(icon 保持白色)
12. **Blog 列表/文章頁**:serif 標題、髮絲線、琥珀 accent,跟新 tokens
13. **SectionHeading**pill eyebrow → 琥珀短線+細字眉題;預設左對齊
## 5. 驗證
- `npm run build` 通過
- `npm run dev` 目測:RWD、錨點、FAQ accordion、WhatsApp link、無 hydration error
## 6. 文件同步
- `src/AGENTS.md`token 名單更新(移除 `sky``bg.hero`;加入 `ink`
- `src/components/site/AGENTS.md`:樣式慣例更新(editorial 風、禁用漸層/膠囊/陰影卡)
@@ -0,0 +1,302 @@
# 盈豐太陽能工程有限公司 — 官網設計規格 (Design Spec)
- **日期**2026-09-11
- **專案代號**yingfungsolar
- **狀態**:已與客戶確認方向,待寫實作計劃
---
## 1. 背景與目標
為香港**村屋太陽能**興建公司「盈豐太陽能工程有限公司」建立一個可以**完全部署在 Cloudflare** 的網站,包含:
- 面向公眾的一頁式官網
- 一個 Blog 分頁(列表 + 文章頁)
- 一個後台 `/admin`,讓非技術人員管理 Blog 及首頁內容
設計要求:美觀、現代化、清爽。內容參考 BrightSun Solar(村屋及工廈太陽能),但**本網站只做村屋**,而且版面設計不參考該站。
### 非目標 (Out of scope)
- 不做工廈 / 農地太陽能內容
- 不做雙語(只做繁體中文)
- 不做聯絡表單(只做 WhatsApp / 電話 / Email 連結)
- 不做圖片上傳(先用佔位圖,後台以 URL 貼圖)
- 不做多頁公司介紹(關於我們等內容併入一頁式)
---
## 2. 技術架構
沿用現有 `cf-dash` 骨幹,路線 A**Astro + React islands + Chakra UI**。
```
┌──────────────── Cloudflare Worker (一個 deploy) ────────────────┐
瀏覽器 → │ Astro 7 │
│ ├─ / SSR,讀 D1 → React island (Chakra UI) │
│ ├─ /blog SSR,讀 D1 │
│ ├─ /blog/[slug] SSR,讀 D1 + Markdown 渲染 │
│ ├─ /admin/* SSRAstro 表單 + HMAC cookie auth │
│ ├─ /sitemap.xml SSR,讀 D1 │
│ └─ /robots.txt 靜態 │
│ │ │
│ D1 (SQLite) ← Drizzle ORM │
│ KV (CACHE) ← 內容版本快取 (可選) │
└────────────────────────────────────────────────────────────────────┘
```
### 技術選型
| 層 | 選用 | 說明 |
|---|---|---|
| 框架 | Astro 7 (`output: "static"` + per-page `prerender = false`) | 沿用現有 |
| Adapter | `@astrojs/cloudflare` | 沿用現有 |
| UI | `@chakra-ui/react` v3 + `@emotion/react`(只淺色模式,不引入 color mode 套件) | 新增 |
| React | React 19(由 `npx astro add react` 加入) | 新增 |
| DB | Cloudflare D1 (SQLite) | 沿用 |
| ORM | Drizzle | 沿用 |
| Auth | HMAC-signed cookie`src/lib/auth.ts` | 沿用 |
| Markdown | `marked``src/lib/markdown.ts` | 沿用 |
### 關鍵渲染決策
1. **首頁改為 SSR**`prerender = false`),因為首頁內容由 D1 提供、後台改完要即時生效。
-`Cache-Control: public, s-maxage=60, stale-while-revalidate=300` 保持效能。
2. **每個頁面用單一 React root island**`client:load`),入面包一個 `<Provider>`
- 原因:Chakra 的 Provider 只需一個;避免多個 island 各自建立 Provider 造成樣式重複。
- Astro 會 SSR render 呢個 islandSEO 無損),再喺 client hydrate。
3. **Chakra v3 SSR 樣式注入**:**已用 spike 實測驗證可行**。
- Versions: Astro 7.3.2 + `@astrojs/react` 6.0.5 + React 19.3 + `@chakra-ui/react` 3.37 + `@emotion/react` 11.14。
- 實測結果:SSR 會將 critical CSS 以 inline `<style data-emotion>` 輸出(首屏即有樣式、零 FOUC);client hydrate 後 emotion 會自動把樣式整合到 `<head>`**無 hydration error**,樣式正確。
- 因此採預設 `react()`streaming),每個 island 由 `<ChakraProvider value={system}>` 包裹。
- 禁止使用 `client:only`(會失去 SEO)。
4. **Admin 沿用 Astro SSR 表單**POST `formData`),不引入 React/Chakra,降低複雜度。
---
## 3. 資料庫設計 (Drizzle / D1)
### 3.1 `posts`(現有,擴充)
| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | text PK | UUID |
| `slug` | text unique | |
| `title` | text | |
| `excerpt` | text | |
| `content` | text | Markdown |
| `meta_description` | text | |
| `cover_image` | text | **新增**,封面圖 URL |
| `tags` | text | **新增**,逗號分隔(可選) |
| `status` | text | `draft` \| `published` |
| `published_at` | integer (timestamp_ms) | |
| `updated_at` | integer (timestamp_ms) | |
### 3.2 `site_settings`(新增,單例 key-value
| 欄位 | 型別 | 說明 |
|---|---|---|
| `key` | text PK | 設定名稱 |
| `value` | text | 值(複雜值以 JSON 字串存放) |
| `updated_at` | integer (timestamp_ms) | |
種子 keys
- 公司:`company_name`, `company_short_name`, `license_no`, `phone`, `whatsapp`, `email`, `address`, `facebook_url`
- Hero`hero_eyebrow`, `hero_title`, `hero_subtitle`, `hero_primary_cta`, `hero_secondary_cta`, `hero_image`
- SEO`seo_default_title`, `seo_default_description`, `og_image`
- 信任列:`trust_stats`JSON 陣列)
### 3.3 `content_items`(新增,重複內容)
| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | text PK | UUID |
| `kind` | text | `service` \| `feature` \| `step` \| `faq` |
| `title` | text | 服務名 / 特色名 / 步驟名 / 問題 |
| `description` | text | 描述 / 答案 |
| `extra` | text | 可選(icon 名、步驟編號等) |
| `sort_order` | integer | 排序 |
| `status` | text | `draft` \| `published` |
| `updated_at` | integer (timestamp_ms) | |
- `service`:首頁服務卡(村屋太陽能相關)
- `feature`:為何揀我哋
- `step`:專業安裝流程(extra 放 `01``05`
- `faq`:常見問題
### 3.4 `cases`(新增,完成案例)
| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | text PK | UUID |
| `title` | text | 例:大埔上碗窯 - 雙玻光伏板 航空鋁支架 |
| `location` | text | 例:大埔半山 |
| `completed_at` | text | 例:2026年2月 |
| `description` | text | |
| `image_url` | text | 佔位圖 URL |
| `sort_order` | integer | |
| `status` | text | `draft` \| `published` |
| `updated_at` | integer (timestamp_ms) | |
索引:`content_items(kind, status, sort_order)``cases(status, sort_order)``posts(status, published_at)`
---
## 4. 路由與頁面
| 路徑 | 渲染 | 內容 |
|---|---|---|
| `/` | SSR | 一頁式官網 |
| `/blog` | SSR | 文章卡片列表 |
| `/blog/[slug]` | SSR | 文章頁(Markdown |
| `/admin` | SSR | 儀表板:文章列表 + 內容管理入口 |
| `/admin/login` | SSR | 登入 |
| `/admin/logout` | SSR | 登出 |
| `/admin/post/[id]` | SSR | 新增/編輯文章 |
| `/admin/settings` | SSR | 公司 / 聯絡 / Hero / SEO 設定 |
| `/admin/content/[kind]` | SSR | 通用編輯器(service/feature/step/faq |
| `/admin/cases` | SSR | 案例列表 |
| `/admin/cases/[id]` | SSR | 新增/編輯案例 |
| `/sitemap.xml` | SSR | 動態 |
| `/robots.txt` | 靜態 | |
### 4.1 首頁 section 順序
1. **Header**sticky,玻璃感):Logo/公司名、錨點導覽(服務、案例、流程、常見問題、Blog)、WhatsApp CTA;手機版 hamburger drawer
2. **Hero**:眉題、主標題、副標、雙 CTA、主視覺圖、信任子彈點
3. **信任列**:電業承辦商牌照號、服務年資/案例數、客戶口碑
4. **服務**(村屋為主,34 卡)
5. **為何揀我哋**feature6 卡)
6. **專業安裝流程**5 步 timeline
7. **完成案例**(案例卡 + 圖)
8. **FAQ**accordion
9. **聯絡 CTA**:電話 / WhatsApp / Email / 地址
10. **Footer**
11. **浮動 WhatsApp 掣**
### 4.2 Blog
- 列表:卡片(封面圖、標題、摘要、日期),空狀態
- 文章頁:封面、標題、日期、Markdown prose、返回連結、SEO/OG
---
## 5. 設計系統(Chakra theme
> **已過時**2026-09-11 前台改為 editorial 極簡風(暖米白+琥珀金+明體標題),以 `2026-09-11-editorial-redesign-design.md` 為準。以下保留作歷史記錄。
檔案:`src/theme/system.ts`,用 `createSystem(defaultConfig, defineConfig(...))`
### 色彩(清新天藍/綠色系)
| Token | 值 | 用途 |
|---|---|---|
| `brand`(主,綠) | `#0B8A5E` 為主,配 50–900 色階 | 主 CTA、標題點綴、標籤 |
| `sky`(副,天藍) | `#0BA5EC` / `#38BDF8` | 圖示、裝飾、漸層 |
| `bg` 頁面 | `#FFFFFF` | 主背景 |
| `bg.subtle` | `#F2F8F6` | 分段背景 |
| `fg` | `#0F1E1A` | 正文 |
| `fg.muted` | `#5B6B66` | 次要文字 |
| `border` | `#E2EDE9` | 卡片描邊 |
- 漸層:天藍 → 青綠(Hero 裝飾光暈)
- 語意 tokens`bg`, `fg`, `border`, `brand.solid`, `brand.contrast`, `brand.muted`
### 字體
- `Noto Sans HK`fallback`PingFang HK`, system sans
- 標題:700/800,負字距;內文:400/500,行高 1.75
### 形狀與動效
- 卡片:白底、圓角 `2xl`1624px)、柔和陰影、細描邊
- 按鈕:`brand.solid` 圓角、hover 微加深
- 動效:捲動淡入/上移,用 CSS + IntersectionObserver,不引入 framer-motion
### 圖像
- 全部先用高質佔位/AI 生成圖,經 `img` URL 提供
- 之後可經後台換成真實工程相
---
## 6. Admin 功能
- 沿用 Astro SSR 表單 + HMAC cookie;共用設計 token 的 CSS,令後台風格與前台一致
- **文章**:列表 / 新增 / 編輯 / 發布 / 刪除(現有,加封面圖欄)
- **設定** `/admin/settings`:公司資料、聯絡、Hero 文案、SEO 預設(分組表單)
- **內容** `/admin/content/[kind]`:通用編輯器,支援新增/編輯/刪除/上下排序;以 `kind` 切換 service/feature/step/faq
- **案例** `/admin/cases`:列表 + 編輯(含圖片 URL、地點、日期)
### 內容更新即時生效
- Admin 儲存 → 首頁 SSR 讀 D1(最長 60 秒邊緣快取後更新)
- (可選)儲存時 bump `CACHE` 內的 `content_version`,於 SSR 檢查以提早失效
---
## 7. SEO / 效能
- 沿用 `Base.astro` 的 canonical / OG / Twitter card;補上 `cover_image``og:image`
- `sitemap.xml` 加入 `/``/blog`、各文章
- `robots.txt` disallow `/admin`
- 首頁 SSR + s-maxage 邊緣快取
- 圖片 lazy load、`loading="lazy"`、尺寸標註
- 目標:Lighthouse SEO/Performance/Accessibility 良好
---
## 8. 部署(Cloudflare
```bash
npx astro add react
npm i @chakra-ui/react @emotion/react
npx wrangler d1 create yingfung-solar-db # 將 database_id 填入 wrangler.jsonc
npx wrangler kv namespace create CACHE # 將 id 填入 wrangler.jsonc
npm run db:generate
npm run db:migrate # 雲端
npm run db:migrate:local # 本機
npx wrangler secret put ADMIN_PASSWORD
# 更新 astro.config.mjs site、src/config.ts、wrangler.jsonc vars
npm run deploy
```
- 需更新 `wrangler.jsonc``name``vars.SITE_NAME` 為盈豐太陽能
- `astro.config.mjs``site` 待客戶提供真域名
---
## 9. 分階段實作
1. **基建**:加 React + Chakra + Provider、theme、換 Base layout、驗證 SSR 樣式
2. **首頁 UI**:全部 section 組件(先佔位內容)
3. **資料層**schema 擴充 + migration + seed(公司資料 + 樣本內容)
4. **Blog UI**:列表 + 文章頁翻新
5. **Admin**settings / content items / cases 編輯器
6. **SEO / 效能**sitemap、快取、Lighthouse
7. **部署**D1/KV/secret、上線
---
## 10. 風險與對策
| 風險 | 對策 |
|---|---|
| Chakra v3 + Astro SSR 樣式注入 | **已實測通過**(見 §2.3);Astro 7 + React 19 + Chakra 3.37 無 hydration error |
| Astro 7 與 `@astrojs/react` / React 19 版本相容 | 用 `npx astro add react` 自動配對版本 |
| 多個 island 造成 Provider 重複 | 每頁只一個 React root island |
| 佔位圖版權 | 使用可商用來源或 AI 生成 |
| 接觸點資訊未齊 | 全部放 `site_settings`,客戶可於後台自行更新 |
---
## 11. 待客戶提供
- 真實電話 / WhatsApp 號碼
- 聯絡 Email
- 辦公地址
- 電業承辦商牌照號碼
- 真域名
- 真實工程相片(可後補)
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "sqlite",
});
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE `posts` (
`id` text PRIMARY KEY NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`excerpt` text,
`content` text NOT NULL,
`meta_description` text,
`status` text DEFAULT 'draft' NOT NULL,
`published_at` integer,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_posts_slug` ON `posts` (`slug`);--> statement-breakpoint
CREATE INDEX `idx_posts_published_at` ON `posts` (`published_at`);--> statement-breakpoint
CREATE INDEX `idx_posts_status` ON `posts` (`status`);
+33
View File
@@ -0,0 +1,33 @@
CREATE TABLE `cases` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`location` text,
`completed_at` text,
`description` text,
`image_url` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`status` text DEFAULT 'published' NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_cases_status` ON `cases` (`status`,`sort_order`);--> statement-breakpoint
CREATE TABLE `content_items` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`title` text NOT NULL,
`description` text DEFAULT '' NOT NULL,
`extra` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`status` text DEFAULT 'published' NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_items_kind` ON `content_items` (`kind`,`status`,`sort_order`);--> statement-breakpoint
CREATE TABLE `site_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text DEFAULT '' NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE `posts` ADD `cover_image` text;--> statement-breakpoint
ALTER TABLE `posts` ADD `tags` text;
+35
View File
@@ -0,0 +1,35 @@
# migrations/AGENTS.md
## Purpose
Cloudflare D1 的 schema migration 同種子資料。
## Ownership
- 擁有 `migrations/` 全部 SQL、`migrations/meta/``seed.sql`
- Drizzle schema 定義喺 `src/db/schema.ts`(見 `src/db/AGENTS.md`)。
## Local Contracts
- **Migration 由工具產生**`0000_*.sql``0001_*.sql``npm run db:generate`drizzle-kit)依 `schema.ts` 產生;`migrations/meta/` 係產生檔。**唔好手改**呢啲檔或 meta。
- **`seed.sql` 係手寫、獨立於 migration**:全部用 `INSERT OR REPLACE`,可重複執行。`db:migrate` **唔會**執行 seed。
- **指令**(本機用 `:local`,雲端省略):
- `npm run db:generate` — 由 schema 產生 migration
- `npm run db:migrate:local` / `npm run db:migrate` — 套用 migration
- `npm run db:seed:local` / `npm run db:seed` — 匯入種子
- **Seed 內容**:公司資料(`site_settings`)、首頁內容(`content_items`service/feature/step/faq)、`cases`、一篇示範 `posts`
- DB 名稱 `yingfung-solar-db`binding 喺 `wrangler.jsonc``database_id` 仍係佔位,部署前要填)。
## Work Guidance
- 改 schema → 先 `db:generate`,再 `db:migrate:local`;改 seed → 直接 `db:seed:local`
- 新增 setting key 時同步更新 `src/data/settings-fields.ts`
## Verification
- `npm run db:migrate:local && npm run db:seed:local`,再用 `npm run db:studio` 或 SQL 查證。
- `npm run build`
## Child DOX Index
無。
+114
View File
@@ -0,0 +1,114 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2547788f-b994-4f91-b149-618f655c643b",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"meta_description": {
"name": "meta_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_posts_slug": {
"name": "idx_posts_slug",
"columns": [
"slug"
],
"isUnique": true
},
"idx_posts_published_at": {
"name": "idx_posts_published_at",
"columns": [
"published_at"
],
"isUnique": false
},
"idx_posts_status": {
"name": "idx_posts_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+323
View File
@@ -0,0 +1,323 @@
{
"version": "6",
"dialect": "sqlite",
"id": "13b1956d-d6ac-4a99-82db-372bdb300834",
"prevId": "2547788f-b994-4f91-b149-618f655c643b",
"tables": {
"cases": {
"name": "cases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_cases_status": {
"name": "idx_cases_status",
"columns": [
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"content_items": {
"name": "content_items",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_items_kind": {
"name": "idx_items_kind",
"columns": [
"kind",
"status",
"sort_order"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover_image": {
"name": "cover_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta_description": {
"name": "meta_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_posts_slug": {
"name": "idx_posts_slug",
"columns": [
"slug"
],
"isUnique": true
},
"idx_posts_published_at": {
"name": "idx_posts_published_at",
"columns": [
"published_at"
],
"isUnique": false
},
"idx_posts_status": {
"name": "idx_posts_status",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_settings": {
"name": "site_settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1789022396039,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1789107074198,
"tag": "0001_regular_multiple_man",
"breakpoints": true
}
]
}
+82
View File
@@ -0,0 +1,82 @@
-- 盈豐太陽能工程有限公司 — 種子資料
-- 用法: npm run db:seed:local / npm run db:seed
-- 全部使用 INSERT OR REPLACE,可重複執行。
INSERT OR REPLACE INTO site_settings (key, value, updated_at) VALUES
('company_name', '盈豐太陽能工程有限公司', strftime('%s','now')*1000),
('company_short_name', '盈豐太陽能', strftime('%s','now')*1000),
('license_no', '電業承辦商編號:待填', strftime('%s','now')*1000),
('phone', '+852 0000 0000', strftime('%s','now')*1000),
('whatsapp', '85200000000', strftime('%s','now')*1000),
('email', '[email protected]', strftime('%s','now')*1000),
('address', '香港新界(地址待填)', strftime('%s','now')*1000),
('facebook_url', '', strftime('%s','now')*1000),
('hero_eyebrow', '香港村屋太陽能專家', strftime('%s','now')*1000),
('hero_title', '善用村屋天台 發電慳電賺回報', strftime('%s','now')*1000),
('hero_subtitle', '由現場評估、方案建議、安裝施工,到申請同跟進,我哋用清晰易明嘅方式,陪你完成整個村屋太陽能項目。', strftime('%s','now')*1000),
('hero_primary_cta', '免費預約評估', strftime('%s','now')*1000),
('hero_secondary_cta', '睇完成案例', strftime('%s','now')*1000),
('hero_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1600&q=80', strftime('%s','now')*1000),
('seo_default_title', '盈豐太陽能工程有限公司 — 香港村屋太陽能一站式服務', strftime('%s','now')*1000),
('seo_default_description', '專營香港村屋太陽能系統:現場評估、合法支架、代辦申請、專業安裝及驗收認證。透明報價,專人跟進,幫你善用天台賺取發電回報。', strftime('%s','now')*1000),
('og_image', 'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1200&q=80', strftime('%s','now')*1000);
INSERT OR REPLACE INTO content_items (id, kind, title, description, extra, sort_order, status, updated_at) VALUES
('svc-001', 'service', '村屋天台太陽能系統', '善用村屋天台空間,安裝高效太陽能板,將閒置天台變成穩定收入來源。', 'sun', 1, 'published', strftime('%s','now')*1000),
('svc-002', 'service', '合法合規支架工程', '按村屋結構同法規設計鋁合金支架,穩固耐用,確保合乎相關要求。', 'shield', 2, 'published', strftime('%s','now')*1000),
('svc-003', 'service', '代辦申請及文件', '由中電/港燈申請、上網電價計劃(FiT)到相關政府文件,全程代辦。', 'file', 3, 'published', strftime('%s','now')*1000),
('svc-004', 'service', '專業安裝及驗收', '由認可人士(AP)及註冊電工施工,簽發所需認證文件,安全合法。', 'check', 4, 'published', strftime('%s','now')*1000),
('svc-005', 'service', '降低頂樓室溫', '太陽能板為天台遮蔭,有效降低頂樓單位溫度,同時慳冷氣電費。', 'snow', 5, 'published', strftime('%s','now')*1000),
('svc-006', 'service', '收益及回本分析', '按日照、面積同電價,提供清晰嘅發電量同回本估算,一目了然。', 'chart', 6, 'published', strftime('%s','now')*1000),
('feat-001', 'feature', '熟悉香港村屋環境', '深耕本地市場,充分掌握村屋結構、法規同地理限制。', 'map', 1, 'published', strftime('%s','now')*1000),
('feat-002', 'feature', '流程清晰易明', '用最簡單直白嘅方式解說回本期同技術細節,絕無花巧術語。', 'file', 2, 'published', strftime('%s','now')*1000),
('feat-003', 'feature', '專人快速跟進', '一對一顧問服務,由評估到完工全程透明,隨時解答疑問。', 'user', 3, 'published', strftime('%s','now')*1000),
('feat-004', 'feature', '美觀與效率兼備', '追求發電效率之餘,極致重視施工整齊度同建築美感。', 'sparkle', 4, 'published', strftime('%s','now')*1000),
('feat-005', 'feature', '重視施工安全', '嚴選耐用器材、嚴格執行安全程序,確保系統長久穩定。', 'shield', 5, 'published', strftime('%s','now')*1000),
('feat-006', 'feature', '不硬銷真誠建議', '先幫你了解安裝可行性,未必即刻做,最緊要係問清楚。', 'heart', 6, 'published', strftime('%s','now')*1000),
('step-001', 'step', '初步諮詢 / 現場評估', '專人上門視察環境,提供專業技術建議。', '01', 1, 'published', strftime('%s','now')*1000),
('step-002', 'step', '申請文件處理', '代辦電力公司同政府部門所需嘅申請文件。', '02', 2, 'published', strftime('%s','now')*1000),
('step-003', 'step', '安裝施工', '專業團隊進場施工,確保工期同施工品質安全。', '03', 3, 'published', strftime('%s','now')*1000),
('step-004', 'step', '驗收認證', '由認可人士及合資格電工簽發 C/PVS、WR1、GF1 等文件。', '04', 4, 'published', strftime('%s','now')*1000),
('step-005', 'step', '掛表正式發電', '電力公司掛上電表後,正式開始享受太陽能發電收益。', '05', 5, 'published', strftime('%s','now')*1000),
('faq-001', 'faq', '村屋天台可以安裝太陽能嗎?', '大部分村屋天台都可以。我哋會先上門評估結構、日照同遮擋情況,再判斷可行性同建議方案。', NULL, 1, 'published', strftime('%s','now')*1000),
('faq-002', 'faq', '安裝太陽能大概幾錢?幾時回本?', '造價視乎面積、支架形式同系統容量。一般村屋系統回本期約 6–9 年,之後發電收益基本上係淨賺。我哋會提供清晰報價同回本估算。', NULL, 2, 'published', strftime('%s','now')*1000),
('faq-003', 'faq', '需要幾大天台面積?', '一般每 1 kW 系統約需 60–80 平方呎,我哋會按實際可用面積設計最合適嘅容量。', NULL, 3, 'published', strftime('%s','now')*1000),
('faq-004', 'faq', '安裝太陽能合唔合法?會唔會影響樓宇結構?', '合規嘅村屋太陽能系統須符合相關法規同電力條例。我哋由設計到施工都依法處理,並由認可人士簽發所需文件。', NULL, 4, 'published', strftime('%s','now')*1000),
('faq-005', 'faq', '系統需要保養嗎?', '需要基本保養,例如定期清洗太陽能板同檢查線路。優質器材一般可用 20–25 年,保養得宜發電更穩定。', NULL, 5, 'published', strftime('%s','now')*1000),
('faq-006', 'faq', '申請上網電價(FiT)麻煩嗎?', '唔麻煩,我哋會代辦中電或港燈嘅上網電價計劃申請,由填表到掛表全程跟進。', NULL, 6, 'published', strftime('%s','now')*1000);
INSERT OR REPLACE INTO cases (id, title, location, completed_at, description, image_url, sort_order, status, updated_at) VALUES
('case-001', '大埔上碗窯 — 雙玻光伏板 航空鋁支架', '大埔半山', '2026年2月', '採用雙玻光伏板配航空級鋁合金支架,兼顧發電效率同天台景觀。', 'https://images.unsplash.com/photo-1613665813446-82a78c468a1d?auto=format&fit=crop&w=1200&q=80', 1, 'published', strftime('%s','now')*1000),
('case-002', '元朗山下村 — 香檳色鋁合金支架', '元朗屏山', '2025年7月', '香檳色支架配合村屋外牆色調,施工整齊,發電穩定。', 'https://images.unsplash.com/photo-1592833159155-c62df1b65634?auto=format&fit=crop&w=1200&q=80', 2, 'published', strftime('%s','now')*1000),
('case-003', '西貢西澳村 — 焗白色鋁合金支架', '新界西貢', '2025年5月', '焗白色支架低調美觀,善用天台每一寸可用空間。', 'https://images.unsplash.com/photo-1508514177221-188b1cf16e9d?auto=format&fit=crop&w=1200&q=80', 3, 'published', strftime('%s','now')*1000);
INSERT OR REPLACE INTO posts (id, slug, title, excerpt, content, cover_image, tags, meta_description, status, published_at, updated_at) VALUES
('post-001', 'village-house-solar-guide', '村屋安裝太陽能前,你要知嘅 5 件事', '想喺村屋天台裝太陽能?由天台面積、日照、支架法規到回本期,呢篇幫你一次過搞清楚。',
'村屋天台係香港少數可以合法大規模安裝太陽能嘅地方。不過落決定之前,以下 5 點你一定要知。
## 1. 天台面積有幾大?
一般每 1 kW 系統約需 60–80 平方呎可用面積。天台有無水箱、樓梯屋或其他遮擋,都會影響實際可安裝容量。
## 2. 日照同遮擋
向南、無遮擋嘅天台發電表現最好。我哋會用現場評估同工具分析你屋企嘅日照情況。
## 3. 支架要合法合規
村屋太陽能支架須符合相關法規。專業設計嘅鋁合金支架唔單止穩固,亦確保合乎要求。
## 4. 回本期有幾長?
一般村屋系統回本期約 6–9 年,之後嘅發電收益基本上係淨賺。實際數字視乎系統容量同電價。
## 5. 邊個幫你申請?
由中電/港燈嘅上網電價計劃(FiT)到政府文件,我哋會全程代辦,你唔需要自己四圍撲。
想知你屋企啱唔啱裝?歡迎 WhatsApp 我哋預約免費評估。',
'https://images.unsplash.com/photo-1509391366360-2e959784a276?auto=format&fit=crop&w=1600&q=80', '村屋,太陽能,指南', '村屋安裝太陽能前必知:天台面積、日照、支架法規、回本期同申請流程,一次講清楚。', 'published', strftime('%s','now')*1000, strftime('%s','now')*1000);
+9477
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "cf-dash",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro build && wrangler dev --port 8787",
"deploy": "astro build && wrangler deploy",
"astro": "astro",
"login": "wrangler login",
"db:create": "wrangler d1 create yingfung-solar-db",
"db:generate": "drizzle-kit generate",
"db:migrate": "wrangler d1 migrations apply yingfung-solar-db --remote",
"db:migrate:local": "wrangler d1 migrations apply yingfung-solar-db --local",
"db:seed": "wrangler d1 execute yingfung-solar-db --remote --file=./migrations/seed.sql",
"db:seed:local": "wrangler d1 execute yingfung-solar-db --local --file=./migrations/seed.sql",
"db:studio": "drizzle-kit studio",
"kv:create": "wrangler kv namespace create CACHE",
"secret": "wrangler secret put ADMIN_PASSWORD",
"types": "wrangler types"
},
"dependencies": {
"@astrojs/cloudflare": "^14.3.1",
"@astrojs/react": "^6.0.5",
"@chakra-ui/react": "^3.37.0",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"astro": "^7.3.2",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.44.0",
"marked": "^18.0.12",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/node": "^22.20.2",
"drizzle-kit": "^0.31.10",
"typescript": "^7.0.2",
"wrangler": "^4.130.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+69
View File
@@ -0,0 +1,69 @@
# src/AGENTS.md
## Purpose
`src/` 係全疊應用程式碼:Astro 路由、React/Chakra 前台 UI、D1/Drizzle 資料層、基礎工具,以及 theme 同 layout。
## Ownership
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
- `theme/system.ts` — Chakra systemtokens / semanticTokens / fonts / globalCss),前台顏色同字體的唯一來源。現行設計:editorial 極簡風(暖米白 `#FAF8F4` 底、琥珀金 `brand` accent、墨黑 `ink`、Noto Serif HK 標題),詳見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`
- `layouts/Base.astro` — 前台 SEO headcanonical / OG / Twitter / sitemap link)+ Noto Sans HK。
- `layouts/AdminLayout.astro` — 後台外框,純 CSS、與前台一致的色系。
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`
- `env.d.ts``App.Locals.isAdmin` 型別宣告。
- `pages/` — 路由層同 `/admin` 後台(詳見下面 Routes & SSR / Admin,因 Astro 限制冇獨立 child doc)。
## Local Contracts
- **架構**Astro 7`output: "static"` + per-page SSR+ `@astrojs/react` + Chakra UI v3Emotion),單一 Cloudflare Worker。
- **環境變數**:一律 `getEnv()``src/lib/env.ts`)。Astro v6 起已移除 `Astro.locals.runtime.env`
- **資料流**`.astro` 頁面 → `getDb(getEnv().DB)``src/data/content.ts` 查詢 → props 傳畀 React island。元件唔直接讀 DB。
- **語言**:所有 UI 文案、註解、後台文案都係繁體中文(廣東話),網站唔做雙語。
- **產生檔唔好手改**`dist/``.astro/``.wrangler/``worker-configuration.d.ts`
### Routes & SSR`pages/`
- **預設靜態**`astro.config.mjs``output: "static"`。要讀 D1 或 request-time 資料的頁面/endpoint 必須在檔案內寫 `export const prerender = false`
- 目前 SSR`index.astro``blog/index.astro``blog/[slug].astro``admin/**``sitemap.xml.ts`
- 保持靜態:`about.astro``robots.txt.ts`
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island`client:load`),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`
- **404**`blog/[slug].astro` 找唔到文章 → 設 `Astro.response.status = 404` 並 render noindex 頁。
- **Endpoint**:用 `APIRoute``sitemap.xml.ts` 由 D1 讀已發布文章並設 `s-maxage=3600`
- **SEO**:用 `Base.astro`canonical / OG / sitemap 全部靠 `astro.config.mjs``site`(現為佔位 `https://example.com`,上線前要改)。
### Admin`pages/admin/`
- 所有 `/admin` 路由**必須** `export const prerender = false`
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro``checkPassword` + `createSession`),登出喺 `logout.ts`
- **UI**:全部用 `AdminLayout.astro`,純 Astro SSR 表單(`POST` + `formData`),**唔引入** React / Chakra。
- **流程**:每個 POST 處理完 `Astro.redirect` 返對應列表頁。
- **通用動作**`add` / `save` / `delete` / `up` / `down`(排序以交換 `sortOrder` 實作)。
- **通用內容編輯器**`content/[kind].astro``kind ∈ service | feature | step | faq`(見 `db/schema.ts``CONTENT_KINDS`),欄位標籤由檔案內 `META` 定義。
- **網站設定**`settings.astro``data/settings-fields.ts``SETTINGS_GROUPS` / `ALL_SETTING_KEYS` 產生表單,逐 key upsert。
- **文章**`index.astro` 列表、`post/[id].astro` 新增/編輯(`id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。
- **圖片**:一律以 URL 字串處理(暫無上傳)。
- 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。
## Work Guidance
- 改前台視覺先睇 `theme/system.ts` 的 tokens,優先重用語意 token,唔好散落硬編色值。
- 新增 endpoint 或 SSR 頁後,確認 `prerender = false` 同相應快取 header 都有。
- 新增一個內容欄位:先改 `db/schema.ts``npm run db:generate`,再改對應 admin 表單同 `data/content.ts`
- **唔好喺 `src/pages/``.md` 文件**(例如 AGENTS.md):Astro 會將佢變成公開路由(今次已實測 `/AGENTS``/admin/AGENTS`)。Astro 只支援用 `_` 前綴豁免,但同 DOX 的 `AGENTS.md` 命名衝突,所以 `pages/` 的合約一律寫喺本文件。其餘 `src/` 子目錄的 `AGENTS.md`data/db/lib/components)唔會被路由。
## Verification
- `npm run build`(唯一的 build/type 驗證;冇 test/lint/typecheck script)。
- `npm run dev`(:4321)目測前台/Blog/後台;測 `/blog/[slug]` 404、登入/登出流程。
## Child DOX Index
| Path | Scope |
|---|---|
| `components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
+36
View File
@@ -0,0 +1,36 @@
# src/components/site/AGENTS.md
## Purpose
前台 React 元件(Astro island 內容),用 Chakra UI v3 建立一頁式官網、Blog 列表同文章頁。
## Ownership
- 擁有 `src/components/site/` 所有 `.tsx` 元件。
- 唔擁有資料讀取(由 `.astro` 傳 props)同 Chakra theme(屬父層 `theme/system.ts`)。
## Local Contracts
- **Chakra v3 + Astro**:一個頁面只掛一個 React root island。`Provider``<ChakraProvider value={system}>`)由 `SiteChrome`(首頁)或 `BlogIndex` / `BlogPost` 包住。**唔可以用 `client:only`**(會失去 SEO)。
- **版型**`Section`(統一 max-width / padding / 背景 tone)+ `SectionHeading` 包每個 section。
- **Chrome**`SiteChrome` 組合 `Header` + `<main>` + `Footer` + `WhatsAppFab`Blog 頁自行組合同一批元件。
- **資料**:全部經 props 傳入(型別來自 `data/content.ts` / `db/schema.ts`);元件**唔直接讀 DB**。
- **樣式 token**:用 `theme/system.ts` 的語意 token`brand.*`=琥珀金、`ink`=墨黑、`fg.muted``border.subtle``bg.subtle`),避免散落硬編色值(現時仍有少量硬編 hex,新增時優先跟 token)。
- **設計風格**editorial 極簡風(見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`)——暖米白底、Noto Serif HK 標題、髮絲線分隔、編號式清單。**禁止**:漸層、背景光斑、`rounded-full` 膠囊掣、pill badge 眉題、陰影浮卡、icon 圓角磚。
- **圖示**`lucide-react` 幼線 icon`strokeWidth={1.5}` 為佳);內容項目**唔再用** `extra` 圖示代碼(`service``feature``extra` 已停用;`step``extra` 係步驟編號 `01``05`)。
- **響應式**:用 Chakra 的 `base` / `md` / `lg` object syntax。
- 文案一律繁體中文(廣東話)。
## Work Guidance
- 新增首頁 section:建立元件 → 喺 `HomePage.tsx` 接入 → 資料由 `getHomeData``data/content.ts`)提供。
- 新元件唔好各自建立 `Provider`,跟返現有最外層包法。
## Verification
- `npm run build`
- `npm run dev` 目測 RWD、錨點、FAQ accordion、WhatsApp link、無 console / hydration error。
## Child DOX Index
無。
+140
View File
@@ -0,0 +1,140 @@
import { Box, Container, Grid, HStack, Heading, Image, Stack, Text } from "@chakra-ui/react";
import { ArrowRight, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export type BlogListItem = {
slug: string;
title: string;
excerpt: string | null;
coverImage: string | null;
dateLabel: string;
};
export function BlogIndex({ posts, settings }: { posts: BlogListItem[]; settings: Settings }) {
const [featured, ...rest] = posts;
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 14, md: 20 }} minH="60vh">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack gap="5" mb={{ base: 10, md: 14 }} pb={{ base: 8, md: 10 }} borderBottomWidth="2px" borderColor="ink">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
BLOG
</Text>
</HStack>
<Heading as="h1" fontSize={{ base: "4xl", md: "6xl" }} fontWeight="900" letterSpacing="-0.01em" color="ink">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
</Text>
</Stack>
{posts.length === 0 ? (
<Text color="fg.muted"></Text>
) : (
<Stack gap={{ base: 10, md: 14 }}>
{/* 最新文章 featured */}
<Grid
asChild
templateColumns={{ base: "1fr", md: "1fr 1fr" }}
gap={{ base: 5, md: 10 }}
alignItems="center"
role="group"
cursor="pointer"
>
<a href={`/blog/${featured.slug}`}>
{featured.coverImage && (
<Box overflow="hidden">
<Image
src={featured.coverImage}
alt={featured.title}
w="full"
aspectRatio="16 / 10"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
)}
<Stack gap="3">
<HStack gap="2" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{featured.dateLabel}
</HStack>
<Heading as="h2" fontSize={{ base: "xl", md: "3xl" }} fontWeight="900" color="ink" lineHeight="1.3" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.excerpt}
</Text>
)}
<Text color="brand.700" fontWeight="600" fontSize="sm">
<ArrowRight size={14} style={{ display: "inline", verticalAlign: "-2px" }} />
</Text>
</Stack>
</a>
</Grid>
{/* 其餘文章:髮絲線清單 */}
{rest.length > 0 && (
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{rest.map((post) => (
<Grid
key={post.slug}
asChild
templateColumns={{ base: "1fr", md: "140px 1fr auto" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 5, md: 6 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
cursor="pointer"
>
<a href={`/blog/${post.slug}`}>
<HStack gap="2" fontSize="xs" letterSpacing="0.08em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{post.dateLabel}
</HStack>
<Stack gap="1">
<Heading as="h2" fontSize={{ base: "lg", md: "xl" }} fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{post.title}
</Heading>
{post.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.7" lineClamp={2}>
{post.excerpt}
</Text>
)}
</Stack>
<Box
color="brand.700"
opacity={{ base: 1, md: 0 }}
transform={{ md: "translateX(-6px)" }}
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
>
<ArrowRight size={20} />
</Box>
</a>
</Grid>
))}
</Stack>
)}
</Stack>
)}
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Box, Button, Container, Heading, Image, Link, Text, VStack } from "@chakra-ui/react";
import { ArrowLeft, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
const prose = {
color: "#3B3835",
fontSize: "1.05rem",
lineHeight: 1.95,
"& h2": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.6rem", fontWeight: 700, mt: "2.5rem", mb: "0.75rem", color: "#1A1917", letterSpacing: "-0.01em" },
"& h3": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.25rem", fontWeight: 700, mt: "2rem", mb: "0.5rem", color: "#1A1917" },
"& p": { mb: "1.25rem" },
"& ul, & ol": { pl: "1.5rem", mb: "1.25rem" },
"& li": { mb: "0.4rem" },
"& a": { color: "#B45309", textDecoration: "underline", textUnderlineOffset: "3px" },
"& strong": { fontWeight: 700, color: "#1A1917" },
"& blockquote": { borderLeft: "3px solid #D97706", pl: "1rem", color: "#6E6862", my: "1.5rem" },
"& img": { my: "1.5rem" },
"& code": { bg: "#F3EFE7", px: "6px", py: "2px", fontSize: "0.9em" },
"& pre": { bg: "#1A1917", color: "#FAF8F4", p: "1rem", overflowX: "auto", mb: "1.5rem" },
};
type Props = {
title: string;
dateLabel: string;
coverImage: string | null;
html: string;
settings: Settings;
};
export function BlogPost({ title, dateLabel, coverImage, html, settings }: Props) {
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 10, md: 16 }}>
<Container maxW="780px" px={{ base: 5, md: 8 }}>
<Link
href="/blog"
display="inline-flex"
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="medium"
color="fg.muted"
mb="8"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
<ArrowLeft size={16} />
Blog
</Link>
<VStack align="flex-start" gap="5" mb="10" pb="8" borderBottomWidth="2px" borderColor="ink">
<Heading as="h1" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.25" color="ink">
{title}
</Heading>
{dateLabel && (
<Text fontSize="xs" letterSpacing="0.1em" fontWeight="medium" color="fg.muted">
<CalendarDays size={14} style={{ display: "inline", marginRight: 6, verticalAlign: "-2px" }} />
{dateLabel}
</Text>
)}
</VStack>
{coverImage && (
<Image src={coverImage} alt={title} w="full" mb="10" objectFit="cover" />
)}
<Box css={prose} dangerouslySetInnerHTML={{ __html: html }} />
<Button asChild variant="outline" mt="12" rounded="none" borderColor="ink" color="ink" _hover={{ bg: "ink", color: "white" }}>
<a href="/blog">
<ArrowLeft size={16} />
</a>
</Button>
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Box, Grid, Heading, HStack, Image, Stack, Text } from "@chakra-ui/react";
import { MapPin } from "lucide-react";
import type { CaseStudy } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Cases({ items }: { items: CaseStudy[] }) {
if (items.length === 0) return null;
const [featured, ...rest] = items;
return (
<Section id="cases" tone="white">
<SectionHeading
eyebrow="完成案例"
title="真實村屋工程案例"
description="我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。"
/>
<Stack gap={{ base: 12, md: 16 }}>
{/* 首案例 featured:雜誌式大圖排版 */}
<Grid
templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }}
gap={{ base: 5, lg: 10 }}
alignItems="end"
role="group"
>
<Box overflow="hidden">
<Image
src={featured.imageUrl ?? undefined}
alt={featured.title}
w="full"
aspectRatio={{ base: "4 / 3", lg: "16 / 10" }}
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<Stack gap="3">
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{featured.completedAt}</Text>
{featured.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{featured.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" lineHeight="1.35" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.description}
</Text>
)}
</Stack>
</Grid>
{rest.length > 0 && (
<Grid templateColumns={{ base: "1fr", md: "repeat(2, 1fr)", lg: `repeat(${Math.min(rest.length, 3)}, 1fr)` }} gap={{ base: 10, md: 8 }}>
{rest.map((item) => (
<Stack key={item.id} gap="3" role="group" borderTopWidth="2px" borderColor="ink" pt="5">
<Box overflow="hidden">
<Image
src={item.imageUrl ?? undefined}
alt={item.title}
w="full"
aspectRatio="4 / 3"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{item.completedAt}</Text>
{item.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{item.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
{item.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
)}
</Stack>
))}
</Grid>
)}
</Stack>
</Section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Box, Button, Container, Grid, HStack, Heading, Stack, Text } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function ContactCta({ settings }: { settings: Settings }) {
const contact = [
{ icon: Phone, label: settings.phone, href: telHref(settings) },
{ icon: Mail, label: settings.email, href: mailHref(settings) },
];
return (
<Box id="contact" bg="ink" color="white" py={{ base: 16, md: 24 }} scrollMarginTop="80px">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }} gap={{ base: 10, lg: 16 }} alignItems="center">
<Stack gap="6" align="flex-start">
<HStack gap="3" color="brand.400">
<Box w="8" h="2px" bg="brand.400" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
</Text>
</HStack>
<Heading as="h2" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.2">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="whiteAlpha.700" maxW="xl">
WhatsApp
</Text>
<HStack gap="4" flexWrap="wrap" pt="2">
<Button asChild size="lg" rounded="none" px="8" bg="brand.600" color="white" fontWeight="700" _hover={{ bg: "brand.500" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
<Button asChild variant="outline" rounded="none" borderColor="whiteAlpha.500" color="white" size="lg" px="8" fontWeight="700" _hover={{ bg: "whiteAlpha.100" }}>
<a href={telHref(settings)}>
<Phone size={18} />
</a>
</Button>
</HStack>
</Stack>
<Stack gap="5" borderLeftWidth={{ lg: "1px" }} borderColor="whiteAlpha.200" pl={{ lg: 12 }}>
{contact.map((c) => (
<HStack key={c.label} gap="3" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400">
<c.icon size={16} />
</Box>
<Text>{c.label}</Text>
</HStack>
))}
<HStack gap="3" align="flex-start" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400" mt="1">
<MapPin size={16} />
</Box>
<Text>{settings.address}</Text>
</HStack>
</Stack>
</Grid>
</Container>
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Accordion, Box, Text } from "@chakra-ui/react";
import { Plus } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Faq({ items }: { items: ContentItem[] }) {
if (items.length === 0) return null;
return (
<Section id="faq">
<SectionHeading
eyebrow="常見問題"
title="安裝前,你可能想知嘅事"
description="仲有其他疑問?歡迎隨時 WhatsApp 問我哋。"
/>
<Box maxW="860px">
<Accordion.Root collapsible defaultValue={["faq-0"]}>
{items.map((item, index) => (
<Accordion.Item
key={item.id}
value={`faq-${index}`}
borderTopWidth="1px"
borderColor="border.subtle"
_last={{ borderBottomWidth: "1px" }}
>
<Accordion.ItemTrigger py="6" px="0" _hover={{ bg: "transparent" }}>
<Text flex="1" textAlign="start" fontFamily="heading" fontWeight="700" color="ink" fontSize={{ base: "md", md: "lg" }}>
{item.title}
</Text>
<Accordion.ItemIndicator color="brand.700" _open={{ transform: "rotate(45deg)" }}>
<Plus size={18} />
</Accordion.ItemIndicator>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody px="0" pb="7" pt="0" color="fg.muted" fontSize="sm" lineHeight="1.9" maxW="2xl">
{item.description}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
))}
</Accordion.Root>
</Box>
</Section>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Box, Heading, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Features({ items }: { items: ContentItem[] }) {
return (
<Section id="why" tone="subtle">
<SectionHeading
eyebrow="點解揀我哋"
title="結合專業工程同貼心溝通"
description="我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。"
/>
<SimpleGrid
columns={{ base: 1, sm: 2, lg: 3 }}
gap="0"
borderWidth="1px"
borderColor="border.subtle"
bg="white"
>
{items.map((item, i) => (
<Stack
key={item.id}
gap="3"
p={{ base: 6, md: 8 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", lg: i < 3 ? "0" : "1px" }}
borderLeftWidth={{
base: "0",
sm: i % 2 === 1 ? "1px" : "0",
lg: i % 3 === 0 ? "0" : "1px",
}}
borderColor="border.subtle"
>
<Text fontFamily="heading" fontWeight="700" fontSize="sm" letterSpacing="0.1em" color="brand.600">
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Box, Container, HStack, Link, Stack, Text, VStack } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function Footer({ settings }: { settings: Settings }) {
const name = settings.company_name || "盈豐太陽能工程有限公司";
const shortName = settings.company_short_name || settings.company_name || "盈豐太陽能";
const year = new Date().getFullYear();
return (
<Box as="footer" bg="ink" color="white" borderTopWidth="1px" borderColor="whiteAlpha.200" pt={{ base: 12, md: 16 }} pb="8">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack
direction={{ base: "column", md: "row" }}
justify="space-between"
gap={{ base: 10, md: 16 }}
>
<VStack align="flex-start" gap="4" maxW="sm">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em">
{shortName}
</Text>
<Text color="whiteAlpha.600" fontSize="sm" lineHeight="1.8">
</Text>
<Text color="whiteAlpha.500" fontSize="xs" letterSpacing="0.08em">
{settings.license_no}
</Text>
</VStack>
<HStack gap={{ base: 10, md: 16 }} align="flex-start">
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
{[
{ label: "服務範圍", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
].map((l) => (
<Link
key={l.href}
href={l.href}
fontSize="sm"
color="whiteAlpha.700"
_hover={{ color: "white", textDecoration: "none" }}
>
{l.label}
</Link>
))}
</VStack>
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Phone size={16} />
<Link href={telHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.phone}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
<Link href={whatsappHref(settings)} target="_blank" rel="noopener" color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
WhatsApp
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Mail size={16} />
<Link href={mailHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.email}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm" align="flex-start">
<MapPin size={16} style={{ marginTop: 3 }} />
<Text>{settings.address}</Text>
</HStack>
</VStack>
</HStack>
</Stack>
<Box borderTopWidth="1px" borderColor="whiteAlpha.200" mt="12" pt="6">
<HStack justify="space-between" flexWrap="wrap" gap="2">
<Text fontSize="xs" color="whiteAlpha.500">
© {year} {name} All rights reserved.
</Text>
<Link href="/admin" fontSize="xs" color="whiteAlpha.500" _hover={{ color: "whiteAlpha.900", textDecoration: "none" }}>
</Link>
</HStack>
</Box>
</Container>
</Box>
);
}
+150
View File
@@ -0,0 +1,150 @@
import {
Box,
Button,
CloseButton,
Container,
Drawer,
Flex,
HStack,
IconButton,
Link,
Portal,
Stack,
Text,
} from "@chakra-ui/react";
import { Menu, Phone } from "lucide-react";
import { telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const NAV = [
{ label: "服務", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
];
export function Header({ settings }: { settings: Settings }) {
const name = settings.company_short_name || settings.company_name || "盈豐太陽能";
const wa = whatsappHref(settings);
return (
<Box
as="header"
position="sticky"
top="0"
zIndex="sticky"
bg="#FAF8F4"
borderBottomWidth="1px"
borderColor="border.subtle"
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Flex h="72px" align="center" justify="space-between" gap="4">
<Stack asChild gap="0.5" cursor="pointer" lineHeight="1.2">
<a href="/">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em" color="ink">
{name}
</Text>
<Text fontSize="11px" letterSpacing="0.12em" color="fg.muted" display={{ base: "none", sm: "block" }}>
{settings.license_no || "村屋太陽能專家"}
</Text>
</a>
</Stack>
<HStack gap="8" display={{ base: "none", lg: "flex" }}>
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
fontSize="sm"
fontWeight="medium"
color="ink"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{item.label}
</Link>
))}
</HStack>
<HStack gap="5">
<Link
href={telHref(settings)}
display={{ base: "none", md: "flex" }}
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="semibold"
color="ink"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
<Phone size={16} />
{settings.phone}
</Link>
<Button
asChild
size="sm"
rounded="none"
px="5"
bg="ink"
color="white"
_hover={{ bg: "brand.700" }}
display={{ base: "none", sm: "inline-flex" }}
>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
</a>
</Button>
<Drawer.Root>
<Drawer.Trigger asChild>
<IconButton aria-label="開啟選單" variant="ghost" display={{ base: "inline-flex", lg: "none" }}>
<Menu />
</IconButton>
</Drawer.Trigger>
<Portal>
<Drawer.Backdrop />
<Drawer.Positioner>
<Drawer.Content bg="#FAF8F4">
<Drawer.Header borderBottomWidth="1px" borderColor="border.subtle">
<Drawer.Title fontFamily="heading" fontWeight="900">{name}</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Stack gap="1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
py="3"
fontWeight="medium"
color="ink"
borderBottomWidth="1px"
borderColor="border.subtle"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
{item.label}
</Link>
))}
</Stack>
</Drawer.Body>
<Drawer.Footer pb="8">
<Button asChild width="full" rounded="none" bg="ink" color="white" _hover={{ bg: "brand.700" }}>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
</Drawer.Footer>
<Drawer.CloseTrigger asChild>
<CloseButton size="sm" />
</Drawer.CloseTrigger>
</Drawer.Content>
</Drawer.Positioner>
</Portal>
</Drawer.Root>
</HStack>
</Flex>
</Container>
</Box>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Box, Button, Container, Grid, HStack, Heading, Image, Link, SimpleGrid, Stack, Text, VStack } from "@chakra-ui/react";
import { ArrowRight, Check } from "lucide-react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const TRUST = ["註冊電業承辦商", "舊客口碑介紹", "一對一專人跟進"];
export function Hero({ settings }: { settings: Settings }) {
const stats = [
{ label: "一般回本期", value: "約 69 年" },
{ label: "系統可用年期", value: "2025 年" },
{ label: "牌照", value: settings.license_no || "註冊電業承辦商" },
];
return (
<Box pt={{ base: 12, md: 20 }} pb={{ base: 16, md: 24 }}>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.05fr 0.95fr" }} gap={{ base: 12, lg: 20 }} alignItems="center">
<VStack align="flex-start" gap="7">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{settings.hero_eyebrow || "香港村屋太陽能專家"}
</Text>
</HStack>
<Heading
as="h1"
fontSize={{ base: "4xl", sm: "5xl", md: "6xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.15"
color="ink"
>
{settings.hero_title}
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="xl">
{settings.hero_subtitle}
</Text>
<HStack gap="6" flexWrap="wrap" pt="1" align="center">
<Button asChild size="lg" rounded="none" px="8" bg="ink" color="white" fontWeight="semibold" _hover={{ bg: "brand.700" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
{settings.hero_primary_cta || "免費預約評估"}
</a>
</Button>
<Link
href="#cases"
fontWeight="semibold"
color="ink"
borderBottomWidth="2px"
borderColor="brand.500"
pb="0.5"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{settings.hero_secondary_cta || "睇完成案例"} <ArrowRight size={15} style={{ display: "inline", verticalAlign: "-2px" }} />
</Link>
</HStack>
<HStack gap={{ base: "4", md: "6" }} flexWrap="wrap" pt="2">
{TRUST.map((t) => (
<HStack key={t} gap="2" color="fg.muted" fontSize="sm">
<Box color="brand.600">
<Check size={15} strokeWidth={3} />
</Box>
<Text>{t}</Text>
</HStack>
))}
</HStack>
</VStack>
<Stack gap="0">
<Image
src={settings.hero_image}
alt="村屋天台太陽能系統"
w="full"
aspectRatio="4 / 3"
objectFit="cover"
/>
<SimpleGrid
columns={3}
borderTopWidth="2px"
borderColor="ink"
pt="5"
mt="6"
gap="4"
>
{stats.map((s) => (
<Stack key={s.label} gap="1">
<Text fontSize="xs" letterSpacing="0.08em" color="fg.muted">
{s.label}
</Text>
<Text fontFamily="heading" fontWeight="700" fontSize={{ base: "md", md: "lg" }} color="brand.700">
{s.value}
</Text>
</Stack>
))}
</SimpleGrid>
</Stack>
</Grid>
</Container>
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { HomeData } from "../../data/content";
import { Cases } from "./Cases";
import { ContactCta } from "./ContactCta";
import { Faq } from "./Faq";
import { Features } from "./Features";
import { Hero } from "./Hero";
import { Process } from "./Process";
import { Services } from "./Services";
import { SiteChrome } from "./SiteChrome";
import { TrustBar } from "./TrustBar";
export function HomePage({ data }: { data: HomeData }) {
const { settings, services, features, steps, faqs, cases } = data;
return (
<SiteChrome settings={settings}>
<Hero settings={settings} />
<TrustBar />
<Services items={services} />
<Features items={features} />
<Process items={steps} />
<Cases items={cases} />
<Faq items={faqs} />
<ContactCta settings={settings} />
</SiteChrome>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Process({ items }: { items: ContentItem[] }) {
return (
<Section id="process">
<SectionHeading
eyebrow="服務流程"
title="專業安裝流程 5 步到位"
description="清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。"
/>
<Box>
<SimpleGrid columns={{ base: 1, md: 5 }} gap={{ base: 10, md: 6 }}>
{items.map((item) => (
<Stack
key={item.id}
align="flex-start"
textAlign="left"
gap="3"
>
<Box
fontFamily="heading"
fontWeight="900"
fontSize="5xl"
lineHeight="1"
color="brand.600"
>
{item.extra}
</Box>
<Text fontFamily="heading" fontWeight="700" fontSize="lg" color="ink">
{item.title}
</Text>
<Text fontSize="sm" color="fg.muted" lineHeight="1.75">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Box>
</Section>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { ChakraProvider } from "@chakra-ui/react";
import type { ReactNode } from "react";
import { system } from "../../theme/system";
export function Provider({ children }: { children: ReactNode }) {
return <ChakraProvider value={system}>{children}</ChakraProvider>;
}
+26
View File
@@ -0,0 +1,26 @@
import { Box, Container, type BoxProps } from "@chakra-ui/react";
import type { ReactNode } from "react";
type SectionProps = BoxProps & {
id?: string;
tone?: "paper" | "subtle" | "white";
children: ReactNode;
};
export function Section({ id, tone = "paper", children, ...rest }: SectionProps) {
const bg = tone === "subtle" ? "bg.subtle" : tone === "white" ? "white" : "transparent";
return (
<Box
as="section"
id={id}
bg={bg}
py={{ base: 16, md: 24 }}
scrollMarginTop="80px"
{...rest}
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
{children}
</Container>
</Box>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, Heading, HStack, Stack, Text } from "@chakra-ui/react";
type Props = {
eyebrow?: string;
title: string;
description?: string;
align?: "center" | "start";
};
export function SectionHeading({ eyebrow, title, description, align = "start" }: Props) {
return (
<Stack gap="5" textAlign={align} align={align === "center" ? "center" : "flex-start"} mb={{ base: 10, md: 14 }}>
{eyebrow && (
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{eyebrow}
</Text>
</HStack>
)}
<Heading
as="h2"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.2"
color="ink"
>
{title}
</Heading>
{description && (
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
{description}
</Text>
)}
</Stack>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Grid, Heading, Stack, Text } from "@chakra-ui/react";
import { ArrowRight } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Services({ items }: { items: ContentItem[] }) {
return (
<Section id="services">
<SectionHeading
eyebrow="服務範圍"
title="專為村屋而設嘅太陽能方案"
description="由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。"
/>
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{items.map((item, i) => (
<Grid
key={item.id}
templateColumns={{ base: "1fr", md: "80px 1fr 2fr 40px" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 6, md: 8 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
>
<Text
fontFamily="heading"
fontWeight="700"
fontSize={{ base: "lg", md: "2xl" }}
color="brand.600"
>
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
<Box
color="brand.700"
opacity="0"
transform="translateX(-6px)"
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
display={{ base: "none", md: "block" }}
>
<ArrowRight size={20} />
</Box>
</Grid>
))}
</Stack>
</Section>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Box } from "@chakra-ui/react";
import type { ReactNode } from "react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export function SiteChrome({ settings, children }: { settings: Settings; children: ReactNode }) {
return (
<Provider>
<Header settings={settings} />
<Box as="main">{children}</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Box, Container, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import { BadgeCheck, FileCheck2, ShieldCheck, Timer } from "lucide-react";
const ITEMS = [
{ icon: ShieldCheck, title: "註冊電業承辦商", desc: "合資格承辦,安全合法" },
{ icon: FileCheck2, title: "上網電價 FiT 代辦", desc: "中電/港燈申請全程跟進" },
{ icon: BadgeCheck, title: "認可人士 AP 簽發", desc: "C/PVS、WR1、GF1 文件" },
{ icon: Timer, title: "2025 年系統壽命", desc: "優質器材,長遠穩定" },
];
export function TrustBar() {
return (
<Box bg="white" borderY="1px solid" borderColor="border.subtle">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} gap="0">
{ITEMS.map((item, i) => (
<Stack
key={item.title}
direction="row"
gap="4"
align="flex-start"
py={{ base: 5, md: 8 }}
px={{ md: 6 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", md: "0" }}
borderLeftWidth={{ base: "0", sm: i % 2 === 1 ? "1px" : "0", md: i === 0 ? "0" : "1px" }}
borderColor="border.subtle"
>
<Box color="brand.700" flexShrink="0" mt="0.5">
<item.icon size={24} strokeWidth={1.5} />
</Box>
<Stack gap="0.5">
<Text fontWeight="700" fontSize="sm" color="ink">
{item.title}
</Text>
<Text fontSize="xs" color="fg.muted">
{item.desc}
</Text>
</Stack>
</Stack>
))}
</SimpleGrid>
</Container>
</Box>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from "@chakra-ui/react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function WhatsAppFab({ settings }: { settings: Settings }) {
return (
<Link
href={whatsappHref(settings)}
target="_blank"
rel="noopener"
position="fixed"
bottom={{ base: "5", md: "7" }}
right={{ base: "5", md: "7" }}
zIndex="dropdown"
boxSize="56px"
rounded="full"
bg="ink"
color="white"
display="flex"
alignItems="center"
justifyContent="center"
boxShadow="0 8px 24px rgba(26,25,23,0.3)"
transition="transform .2s"
_hover={{ transform: "scale(1.08)", textDecoration: "none", bg: "brand.700" }}
aria-label="WhatsApp 查詢"
>
<WhatsAppIcon style={{ width: 28, height: 28 }} />
</Link>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function WhatsAppIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413" />
</svg>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* 網站基本資料(build-time 靜態頁用)。
* 公司資料、聯絡方式等「可管理」內容放喺 D1 嘅 site_settings,於 /admin 修改。
*/
export const SITE_NAME = "盈豐太陽能工程有限公司";
export const SITE_TAGLINE = "香港村屋太陽能一站式服務";
+33
View File
@@ -0,0 +1,33 @@
# src/data/AGENTS.md
## Purpose
D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`settings-fields.ts`)。
## Ownership
- 擁有 `src/data/` 兩個檔案。
- Drizzle schema 本體喺 `src/db/schema.ts`(見兄弟 `db/AGENTS.md`),呢度只消費其型別。
## Local Contracts
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
- **只回前台可見**`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder``publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
- **首頁聚合**`getHomeData(db)``Promise.all` 一次過攞 settings + services/features/steps/faqs + cases,形狀係 `HomeData`
- **`Db` 型別**`DrizzleD1Database<typeof schema>`
- **連結 helper**`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
- **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `migrations/seed.sql`
- 顯示用文案繁體中文。
## Work Guidance
- 新增可管理欄位:改 `settings-fields.ts` → 加 seed default → 喺前端消費(多數喺 `components/site/*`)。
## Verification
- `npm run build`
- `npm run db:seed:local``/admin` 同前台睇得到種子資料。
## Child DOX Index
無。
+104
View File
@@ -0,0 +1,104 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
cases,
contentItems,
posts,
siteSettings,
type CaseStudy,
type ContentItem,
type ContentKind,
type Post,
} from "../db/schema";
import * as schema from "../db/schema";
export type Db = DrizzleD1Database<typeof schema>;
export type Settings = Record<string, string>;
export type HomeData = {
settings: Settings;
services: ContentItem[];
features: ContentItem[];
steps: ContentItem[];
faqs: ContentItem[];
cases: CaseStudy[];
};
export async function getSettings(db: Db): Promise<Settings> {
const rows = await db.select().from(siteSettings);
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
export async function getItems(db: Db, kind: ContentKind): Promise<ContentItem[]> {
return db
.select()
.from(contentItems)
.where(and(eq(contentItems.kind, kind), eq(contentItems.status, "published")))
.orderBy(asc(contentItems.sortOrder));
}
export async function getCases(db: Db): Promise<CaseStudy[]> {
return db
.select()
.from(cases)
.where(eq(cases.status, "published"))
.orderBy(asc(cases.sortOrder));
}
export async function getPublishedPosts(db: Db): Promise<Post[]> {
return db
.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
}
export async function getPostBySlug(db: Db, slug: string): Promise<Post | null> {
const [post] = await db
.select()
.from(posts)
.where(and(eq(posts.slug, slug), eq(posts.status, "published")))
.limit(1);
return post ?? null;
}
export async function getHomeData(db: Db): Promise<HomeData> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
getItems(db, "service"),
getItems(db, "feature"),
getItems(db, "step"),
getItems(db, "faq"),
getCases(db),
]);
return { settings, services, features, steps, faqs, cases: caseList };
}
export function digits(input: string | undefined): string {
return (input ?? "").replace(/\D/g, "");
}
export function whatsappHref(
settings: Settings,
text = "你好,我想查詢村屋太陽能安裝,想預約免費評估。",
): string {
return `https://api.whatsapp.com/send?phone=${digits(settings.whatsapp)}&text=${encodeURIComponent(text)}`;
}
export function telHref(settings: Settings): string {
const phone = (settings.phone ?? "").replace(/[^\d+]/g, "");
return `tel:${phone}`;
}
export function mailHref(settings: Settings): string {
return `mailto:${settings.email ?? ""}?subject=${encodeURIComponent("查詢村屋太陽能安裝")}`;
}
export function formatDate(d: Date | null | undefined): string {
if (!d) return "";
return new Date(d).toLocaleDateString("zh-Hant-HK", {
year: "numeric",
month: "long",
day: "numeric",
});
}
+55
View File
@@ -0,0 +1,55 @@
export type FieldType = "text" | "textarea" | "url";
export type SettingsField = {
key: string;
label: string;
type?: FieldType;
placeholder?: string;
};
export type SettingsGroup = {
title: string;
fields: SettingsField[];
};
export const SETTINGS_GROUPS: SettingsGroup[] = [
{
title: "公司資料",
fields: [
{ key: "company_name", label: "公司全名" },
{ key: "company_short_name", label: "公司簡稱" },
{ key: "license_no", label: "電業承辦商編號" },
{ key: "address", label: "辦公地址", type: "textarea" },
{ key: "facebook_url", label: "Facebook 連結", type: "url", placeholder: "https://..." },
],
},
{
title: "聯絡方式",
fields: [
{ key: "phone", label: "查詢電話", placeholder: "+852 9899 5499" },
{ key: "whatsapp", label: "WhatsApp 號碼(純數字)", placeholder: "85298995499" },
{ key: "email", label: "聯絡 Email", placeholder: "[email protected]" },
],
},
{
title: "首頁 Hero",
fields: [
{ key: "hero_eyebrow", label: "眉題" },
{ key: "hero_title", label: "主標題" },
{ key: "hero_subtitle", label: "副標題", type: "textarea" },
{ key: "hero_primary_cta", label: "主要按鈕文字" },
{ key: "hero_secondary_cta", label: "次要按鈕文字" },
{ key: "hero_image", label: "主視覺圖片 URL", type: "url", placeholder: "https://..." },
],
},
{
title: "SEO 設定",
fields: [
{ key: "seo_default_title", label: "預設標題" },
{ key: "seo_default_description", label: "預設描述", type: "textarea" },
{ key: "og_image", label: "分享圖片 URL (OG image)", type: "url", placeholder: "https://..." },
],
},
];
export const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
+33
View File
@@ -0,0 +1,33 @@
# src/db/AGENTS.md
## Purpose
Drizzle schema 定義,係 D1 migration 的唯一來源。
## Ownership
- 擁有 `src/db/schema.ts`
- 產生出嚟的 SQL 喺 `migrations/`(見 `migrations/AGENTS.md`)。
## Local Contracts
- **表**`posts``site_settings``content_items``cases`
- **可重複內容**`content_items``kind` 區分,`CONTENT_KINDS = ["service", "feature", "step", "faq"]`
- **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published`)。
- **型別**:由 `$inferSelect` 匯出(`Post``ContentItem``CaseStudy``SiteSetting` 等),其他地方重用呢啲型別。
- **改 schema 流程**:改 `schema.ts``npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql``seed.sql` 除外)。
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)`)。
## Work Guidance
- 加欄位時考慮預設值同 nullability,因為 migration 會套用到已有資料。
- 改完 schema 記得同步 `migrations/seed.sql` 同 admin 表單(`pages/admin`)。
## Verification
- `npm run db:generate`(確認 migration 有正確產生)。
- `npm run build`
## Child DOX Index
無。
+97
View File
@@ -0,0 +1,97 @@
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
/**
* Blog 文章。
*/
export const posts = sqliteTable(
"posts",
{
id: text("id").primaryKey(),
slug: text("slug").notNull(),
title: text("title").notNull(),
excerpt: text("excerpt"),
content: text("content").notNull(),
coverImage: text("cover_image"),
tags: text("tags"),
metaDescription: text("meta_description"),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("draft"),
publishedAt: integer("published_at", { mode: "timestamp_ms" }),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [
uniqueIndex("idx_posts_slug").on(t.slug),
index("idx_posts_published_at").on(t.publishedAt),
index("idx_posts_status").on(t.status),
],
);
/**
* 單例設定(公司資料、Hero 文案、SEO 等),以 key-value 存放。
* 複雜值(例如 JSON 陣列)以 JSON 字串存。
*/
export const siteSettings = sqliteTable("site_settings", {
key: text("key").primaryKey(),
value: text("value").notNull().default(""),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
});
export const CONTENT_KINDS = ["service", "feature", "step", "faq"] as const;
export type ContentKind = (typeof CONTENT_KINDS)[number];
/**
* 可重複的內容項目:服務 / 為何揀我哋 / 流程步驟 / 常見問題。
* 用同一個表以 `kind` 區分,方便後台用一個通用編輯器管理。
*/
export const contentItems = sqliteTable(
"content_items",
{
id: text("id").primaryKey(),
kind: text("kind", { enum: CONTENT_KINDS }).notNull(),
title: text("title").notNull(),
description: text("description").notNull().default(""),
extra: text("extra"),
sortOrder: integer("sort_order").notNull().default(0),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("published"),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [index("idx_items_kind").on(t.kind, t.status, t.sortOrder)],
);
/**
* 完成案例。
*/
export const cases = sqliteTable(
"cases",
{
id: text("id").primaryKey(),
title: text("title").notNull(),
location: text("location"),
completedAt: text("completed_at"),
description: text("description"),
imageUrl: text("image_url"),
sortOrder: integer("sort_order").notNull().default(0),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("published"),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [index("idx_cases_status").on(t.status, t.sortOrder)],
);
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type ContentItem = typeof contentItems.$inferSelect;
export type CaseStudy = typeof cases.$inferSelect;
export type SiteSetting = typeof siteSettings.$inferSelect;
+8
View File
@@ -0,0 +1,8 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference path="../worker-configuration.d.ts" />
declare namespace App {
interface Locals {
isAdmin: boolean;
}
}
+113
View File
@@ -0,0 +1,113 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
const path = Astro.url.pathname;
const links = [
{ href: "/admin", label: "文章" },
{ href: "/admin/content/service", label: "服務" },
{ href: "/admin/content/feature", label: "特色" },
{ href: "/admin/content/step", label: "流程" },
{ href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", label: "案例" },
{ href: "/admin/settings", label: "設定" },
];
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — 後台</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<header class="bar">
<div class="inner">
<a class="brand" href="/admin">
<span class="dot"></span>
盈豐太陽能 · 後台
</a>
<nav>
{
links.map((l) => (
<a href={l.href} class={path === l.href || path.startsWith(l.href + "/") ? "active" : ""}>
{l.label}
</a>
))
}
<a href="/" target="_blank">睇網站</a>
<a href="/admin/logout">登出</a>
</nav>
</div>
</header>
<main class="inner">
<slot />
</main>
</body>
</html>
<style is:global>
:root {
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
color: #0f1e1a;
background: #f2f8f6;
line-height: 1.65;
font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; }
a { color: #0b8a5e; text-decoration: none; }
a:hover { text-decoration: underline; }
.inner { max-width: 920px; margin: 0 auto; padding: 0 24px; }
.bar { background: #fff; border-bottom: 1px solid rgba(0,0,0,.08); position: sticky; top: 0; z-index: 10; }
.bar .inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; flex-wrap: wrap; }
.brand { font-weight: 800; color: #0f1e1a; display: inline-flex; align-items: center; gap: 8px; }
.brand:hover { text-decoration: none; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: linear-gradient(135deg,#0b8a5e,#0ba5ec); display: inline-block; }
.bar nav { display: flex; gap: 14px; font-size: 13.5px; flex-wrap: wrap; }
.bar nav a { color: #33413c; }
.bar nav a.active { color: #0b8a5e; font-weight: 700; }
main { padding: 32px 24px 100px; }
h1 { font-size: 22px; font-weight: 800; margin: 0 0 6px; }
.sub { color: #5b6b66; font-size: 13.5px; margin: 0 0 24px; }
.card { background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; padding: 20px 22px; margin-bottom: 18px; }
.card h2 { font-size: 15px; font-weight: 700; margin: 0 0 14px; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; overflow: hidden; }
th { text-align: left; font-weight: 600; font-size: 12px; color: #5b6b66; padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,.08); }
td { padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,.05); font-size: 14px; vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
label { display: block; font-size: 13px; color: #5b6b66; margin: 14px 0 6px; font-weight: 600; }
input[type="text"], input[type="url"], textarea, select {
width: 100%; font: inherit; font-size: 14px; padding: 9px 12px;
border: 1px solid rgba(0,0,0,.15); border-radius: 9px; background: #fff; color: #0f1e1a;
}
textarea { min-height: 88px; resize: vertical; }
input:focus, textarea:focus, select:focus { outline: 2px solid #9fdcc2; border-color: #0b8a5e; }
button, .btn {
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: 9px; cursor: pointer;
border: 1px solid #0b8a5e; background: #0b8a5e; color: #fff; display: inline-flex; align-items: center; gap: 6px;
}
button:hover { background: #0a6f4c; }
button.secondary, .btn.secondary { background: #fff; color: #0f1e1a; border-color: rgba(0,0,0,.15); }
button.secondary:hover { background: #f2f8f6; }
button.danger { background: #fff; color: #a32d2d; border-color: #f09595; }
button.mini { padding: 5px 10px; font-size: 12.5px; border-radius: 7px; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 20px; flex-wrap: wrap; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; }
.badge.published { background: #e1f5ee; color: #0f6e56; }
.badge.draft { background: #faeeda; color: #854f0b; }
.muted { color: #8a9793; font-size: 13px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
</style>
+73
View File
@@ -0,0 +1,73 @@
---
import { SITE_NAME } from "../config";
interface Props {
title: string;
description?: string;
noindex?: boolean;
ogType?: "website" | "article";
publishedTime?: Date | null;
image?: string;
}
const {
title,
description = "香港村屋太陽能一站式服務",
noindex = false,
ogType = "website",
publishedTime = null,
image,
} = Astro.props;
const siteName = SITE_NAME;
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
const canonical = new URL(Astro.url.pathname, origin + "/").toString();
const fullTitle = title.includes(siteName) ? title : `${title} — ${siteName}`;
const ogImage = image ? (image.startsWith("http") ? image : new URL(image, origin + "/").toString()) : undefined;
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteName} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="zh_HK" />
{ogImage && <meta property="og:image" content={ogImage} />}
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<link rel="sitemap" href="/sitemap.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@400;500;700;900&family=Noto+Serif+HK:wght@600;700;900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
html,
body {
margin: 0;
padding: 0;
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
background: #faf8f4;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
# src/lib/AGENTS.md
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。
## Ownership
- 擁有 `src/lib/` 四個檔案。
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## Local Contracts
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
## Work Guidance
- 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts`
- Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。
## Verification
- `npm run build`
- 認證改動:`npm run dev` 測登入/登出/未登入導向。
## Child DOX Index
無。
+67
View File
@@ -0,0 +1,67 @@
export const SESSION_COOKIE = "admin_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 日
const enc = new TextEncoder();
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
function toBase64Url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function sign(data: string, secret: string): Promise<string> {
const key = await hmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
return toBase64Url(sig);
}
/** 長度相等時逐字元 XOR,避免 timing attack */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* 比較密碼。兩邊各自做一次 HMAC 先比,
* 避免直接比原文洩漏長度同前綴(timing attack)。
*/
export async function checkPassword(input: string, secret: string): Promise<boolean> {
return safeEqual(await sign(input, secret), await sign(secret, secret));
}
export async function createSession(secret: string): Promise<string> {
const exp = String(Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SEC);
return `${exp}.${await sign(exp, secret)}`;
}
export async function verifySession(
cookie: string | undefined,
secret: string,
): Promise<boolean> {
if (!cookie) return false;
const [exp, sig] = cookie.split(".");
if (!exp || !sig) return false;
if (Number(exp) * 1000 < Date.now()) return false;
return safeEqual(await sign(exp, secret), sig);
}
export const sessionCookieOptions = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
secure: true,
maxAge: SESSION_MAX_AGE_SEC,
};
+8
View File
@@ -0,0 +1,8 @@
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../db/schema";
export function getDb(env: D1Database): DrizzleD1Database<typeof schema> {
return drizzle(env, { schema });
}
export { schema };
+16
View File
@@ -0,0 +1,16 @@
import { env } from "cloudflare:workers";
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
};
/**
* Astro v6 起,`Astro.locals.runtime.env` 已經移除,
* 一律改用 `cloudflare:workers` 嘅 env。
* 只可以喺 on-demandprerender = false)嘅頁面/endpoint 用。
*/
export function getEnv(): AppEnv {
return env as unknown as AppEnv;
}
+32
View File
@@ -0,0 +1,32 @@
import { marked } from "marked";
marked.setOptions({ gfm: true, breaks: false });
/** markdown → HTML。內容係你自己寫嘅(受信任),所以唔做 sanitize。 */
export function renderMarkdown(md: string): string {
return marked.parse(md ?? "", { async: false }) as string;
}
/** 由標題自動產生 slug:去符號、空格轉 -,保留中文字 */
export function slugify(input: string): string {
return (
input
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || `post-${Date.now()}`
);
}
/** 攞文章開頭做 SEO description(如果冇手填) */
export function autoExcerpt(md: string, len = 150): string {
const plain = md
.replace(/^#+\s.*$/gm, "")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_`>#-]/g, "")
.replace(/\s+/g, " ")
.trim();
return plain.length > len ? plain.slice(0, len) + "…" : plain;
}
+35
View File
@@ -0,0 +1,35 @@
import { defineMiddleware } from "astro:middleware";
import { SESSION_COOKIE, verifySession } from "./lib/auth";
import { getEnv } from "./lib/env";
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.isAdmin = false;
const url = new URL(context.request.url);
if (!url.pathname.startsWith("/admin")) return next();
let password: string | undefined;
try {
password = getEnv().ADMIN_PASSWORD;
} catch {
password = undefined;
}
if (!password) {
// 未設 ADMIN_PASSWORD 就咪畀入,費事人人都係 admin
return new Response("Admin 未設定:請執行 `npm run secret` 設定 ADMIN_PASSWORD。", {
status: 503,
});
}
const cookie = context.cookies.get(SESSION_COOKIE)?.value;
if (await verifySession(cookie, password)) {
context.locals.isAdmin = true;
}
if (!context.locals.isAdmin && url.pathname !== "/admin/login") {
return context.redirect("/admin/login");
}
return next();
});
+12
View File
@@ -0,0 +1,12 @@
---
import Base from "../layouts/Base.astro";
import { SITE_NAME } from "../config";
---
<Base title="關於我哋" description={`${SITE_NAME} — 香港村屋太陽能一站式服務。`} noindex={true}>
<div style="max-width:640px;margin:15vh auto;padding:0 24px;text-align:center;font-family:'Noto Sans HK',sans-serif;">
<h1 style="font-size:28px;font-weight:800;color:#1A1917;">我哋嘅服務已整合到主頁</h1>
<p style="color:#6E6862;line-height:1.9;">請返去主頁睇晒我哋嘅村屋太陽能服務、案例同流程。</p>
<p><a href="/" style="color:#B45309;font-weight:600;">← 返去主頁</a></p>
</div>
</Base>
+172
View File
@@ -0,0 +1,172 @@
---
import { and, asc, desc, eq, gt, lt, max } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { cases } from "../../db/schema";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
await db.insert(cases).values({
id: crypto.randomUUID(),
title,
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
});
}
} else if (action === "save") {
const id = str(form, "id");
await db
.update(cases)
.set({
title: str(form, "title"),
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
})
.where(eq(cases.id, id));
} else if (action === "delete") {
await db.delete(cases).where(eq(cases.id, str(form, "id")));
} else if (action === "up" || action === "down") {
const id = str(form, "id");
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
if (current) {
const [adj] = await db
.select()
.from(cases)
.where(
action === "up" ? lt(cases.sortOrder, current.sortOrder) : gt(cases.sortOrder, current.sortOrder),
)
.orderBy(action === "up" ? desc(cases.sortOrder) : asc(cases.sortOrder))
.limit(1);
if (adj) {
await db.update(cases).set({ sortOrder: adj.sortOrder }).where(eq(cases.id, current.id));
await db.update(cases).set({ sortOrder: current.sortOrder }).where(eq(cases.id, adj.id));
}
}
}
return Astro.redirect("/admin/cases");
}
const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
---
<AdminLayout title="完成案例">
<h1>完成案例</h1>
<p class="sub">前台「完成案例」區塊會按下面次序顯示(只顯示已發布)。</p>
<div class="card">
<h2>新增案例</h2>
<form method="post">
<input type="hidden" name="action" value="add" />
<div class="grid2">
<div>
<label for="a-title">標題</label>
<input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" />
</div>
<div>
<label for="a-loc">地點</label>
<input id="a-loc" name="location" type="text" placeholder="例:大埔半山" />
</div>
<div>
<label for="a-date">施工日期</label>
<input id="a-date" name="completedAt" type="text" placeholder="例:2026年2月" />
</div>
<div>
<label for="a-img">圖片 URL</label>
<input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
</div>
<div style="grid-column:1/-1">
<label for="a-desc">描述</label>
<textarea id="a-desc" name="description"></textarea>
</div>
</div>
<div class="actions"><button type="submit">+ 新增案例</button></div>
</form>
</div>
{
list.length === 0 ? (
<p class="muted">暫時未有案例。</p>
) : (
list.map((item, i) => (
<div class="card">
<form method="post">
<input type="hidden" name="id" value={item.id} />
<div class="grid2">
<div>
<label>標題</label>
<input name="title" type="text" value={item.title} required />
</div>
<div>
<label>地點</label>
<input name="location" type="text" value={item.location ?? ""} />
</div>
<div>
<label>施工日期</label>
<input name="completedAt" type="text" value={item.completedAt ?? ""} />
</div>
<div>
<label>圖片 URL</label>
<input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
</div>
<div style="grid-column:1/-1">
<label>描述</label>
<textarea name="description">{item.description ?? ""}</textarea>
</div>
<div>
<label>狀態</label>
<select name="status">
<option value="published" selected={item.status === "published"}>已發布</option>
<option value="draft" selected={item.status === "draft"}>草稿</option>
</select>
</div>
</div>
{item.imageUrl && (
<img src={item.imageUrl} alt={item.title} style="margin-top:14px;max-width:220px;border-radius:10px;" />
)}
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<button type="submit" name="action" value="up" class="secondary mini" disabled={i === 0}>↑ 上移</button>
<button type="submit" name="action" value="down" class="secondary mini" disabled={i === list.length - 1}>
↓ 下移
</button>
<button
type="submit"
name="action"
value="delete"
class="danger mini"
onclick="return confirm('確定刪除?')"
>
刪除
</button>
</div>
</form>
</div>
))
)
}
</AdminLayout>
+219
View File
@@ -0,0 +1,219 @@
---
import { and, asc, desc, eq, gt, lt, max } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { contentItems, CONTENT_KINDS, type ContentKind } from "../../../db/schema";
import { getDb } from "../../../lib/db";
import { getEnv } from "../../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const kind = Astro.params.kind as ContentKind;
if (!CONTENT_KINDS.includes(kind)) {
return Astro.redirect("/admin");
}
const META: Record<
ContentKind,
{ label: string; extraLabel: string | null; extraHint: string; titleLabel: string; descLabel: string }
> = {
service: {
label: "服務範圍",
extraLabel: null,
extraHint: "",
titleLabel: "服務名稱",
descLabel: "描述",
},
feature: {
label: "為何揀我哋",
extraLabel: null,
extraHint: "",
titleLabel: "特色名稱",
descLabel: "描述",
},
step: {
label: "服務流程",
extraLabel: "步驟編號",
extraHint: "01、02、03…",
titleLabel: "步驟名稱",
descLabel: "描述",
},
faq: {
label: "常見問題",
extraLabel: null,
extraHint: "",
titleLabel: "問題",
descLabel: "答案",
},
};
const meta = META[kind];
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
const [row] = await db
.select({ m: max(contentItems.sortOrder) })
.from(contentItems)
.where(eq(contentItems.kind, kind));
await db.insert(contentItems).values({
id: crypto.randomUUID(),
kind,
title,
description: str(form, "description"),
extra: str(form, "extra") || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
});
}
} else if (action === "save") {
const id = str(form, "id");
await db
.update(contentItems)
.set({
title: str(form, "title"),
description: str(form, "description"),
extra: str(form, "extra") || null,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
})
.where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
} else if (action === "delete") {
await db
.delete(contentItems)
.where(and(eq(contentItems.id, str(form, "id")), eq(contentItems.kind, kind)));
} else if (action === "up" || action === "down") {
const id = str(form, "id");
const [current] = await db.select().from(contentItems).where(eq(contentItems.id, id)).limit(1);
if (current) {
const [adj] = await db
.select()
.from(contentItems)
.where(
and(
eq(contentItems.kind, kind),
action === "up"
? lt(contentItems.sortOrder, current.sortOrder)
: gt(contentItems.sortOrder, current.sortOrder),
),
)
.orderBy(action === "up" ? desc(contentItems.sortOrder) : asc(contentItems.sortOrder))
.limit(1);
if (adj) {
await db.update(contentItems).set({ sortOrder: adj.sortOrder }).where(eq(contentItems.id, current.id));
await db.update(contentItems).set({ sortOrder: current.sortOrder }).where(eq(contentItems.id, adj.id));
}
}
}
return Astro.redirect(`/admin/content/${kind}`);
}
const items = await db
.select()
.from(contentItems)
.where(eq(contentItems.kind, kind))
.orderBy(asc(contentItems.sortOrder));
---
<AdminLayout title={meta.label}>
<h1>{meta.label}</h1>
<p class="sub">前台首頁會按下面次序顯示(只顯示「已發布」項目)。用 ↑↓ 調整排序。</p>
<div class="card">
<h2>新增項目</h2>
<form method="post">
<input type="hidden" name="action" value="add" />
<div class="grid2">
<div>
<label for="add-title">{meta.titleLabel}</label>
<input id="add-title" name="title" type="text" required />
</div>
{
meta.extraLabel && (
<div>
<label for="add-extra">{meta.extraLabel}</label>
<input id="add-extra" name="extra" type="text" placeholder={meta.extraHint} />
</div>
)
}
<div style="grid-column:1/-1">
<label for="add-desc">{meta.descLabel}</label>
<textarea id="add-desc" name="description"></textarea>
</div>
</div>
<div class="actions">
<button type="submit">+ 新增</button>
</div>
</form>
</div>
{
items.length === 0 ? (
<p class="muted">暫時未有項目。</p>
) : (
items.map((item, i) => (
<div class="card">
<form method="post">
<input type="hidden" name="id" value={item.id} />
<div class="grid2">
<div>
<label>{meta.titleLabel}</label>
<input name="title" type="text" value={item.title} required />
</div>
{meta.extraLabel && (
<div>
<label>{meta.extraLabel}</label>
<input name="extra" type="text" value={item.extra ?? ""} placeholder={meta.extraHint} />
</div>
)}
<div style="grid-column:1/-1">
<label>{meta.descLabel}</label>
<textarea name="description">{item.description}</textarea>
</div>
<div>
<label>狀態</label>
<select name="status">
<option value="published" selected={item.status === "published"}>已發布</option>
<option value="draft" selected={item.status === "draft"}>草稿</option>
</select>
</div>
</div>
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<button type="submit" name="action" value="up" class="secondary mini" disabled={i === 0}>↑ 上移</button>
<button
type="submit"
name="action"
value="down"
class="secondary mini"
disabled={i === items.length - 1}
>
↓ 下移
</button>
<button
type="submit"
name="action"
value="delete"
class="danger mini"
onclick="return confirm('確定刪除?')"
>
刪除
</button>
</div>
</form>
</div>
))
)
}
</AdminLayout>
+75
View File
@@ -0,0 +1,75 @@
---
import { desc, eq, sql } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { cases, contentItems, posts } from "../../db/schema";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const all = await db.select().from(posts).orderBy(desc(posts.updatedAt));
const counts = await db
.select({ kind: contentItems.kind, n: sql<number>`count(*)` })
.from(contentItems)
.groupBy(contentItems.kind);
const countOf = (k: string) => counts.find((c) => c.kind === k)?.n ?? 0;
const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
---
<AdminLayout title="文章">
<h1>內容總覽</h1>
<p class="sub">管理文章、首頁內容同完成案例。前台會自動更新。</p>
<div class="card">
<h2>首頁內容</h2>
<div class="actions" style="margin-top:0">
<a class="btn secondary" href="/admin/content/service">服務({countOf("service")}</a>
<a class="btn secondary" href="/admin/content/feature">特色({countOf("feature")}</a>
<a class="btn secondary" href="/admin/content/step">流程({countOf("step")}</a>
<a class="btn secondary" href="/admin/content/faq">常見問題({countOf("faq")}</a>
<a class="btn secondary" href="/admin/cases">完成案例({caseCount?.n ?? 0}</a>
<a class="btn secondary" href="/admin/settings">網站設定</a>
</div>
</div>
<div class="actions">
<h2 style="font-size:18px;margin:0;flex:1">文章({all.length}</h2>
<a class="btn" href="/admin/post/new">+ 寫新文</a>
</div>
{
all.length === 0 ? (
<div class="card">
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
</div>
) : (
<table>
<thead>
<tr>
<th>標題</th>
<th style="width:110px">狀態</th>
<th style="width:130px">更新時間</th>
<th style="width:80px"></th>
</tr>
</thead>
<tbody>
{all.map((p) => (
<tr>
<td>
<a href={`/admin/post/${p.id}`}>{p.title}</a>
<div class="muted">/{p.slug}</div>
</td>
<td>
<span class={`badge ${p.status}`}>{p.status === "published" ? "已發布" : "草稿"}</span>
</td>
<td class="muted">{new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")}</td>
<td>{p.status === "published" && <a href={`/blog/${p.slug}`} target="_blank">睇</a>}</td>
</tr>
))}
</tbody>
</table>
)
}
</AdminLayout>
+59
View File
@@ -0,0 +1,59 @@
---
import { checkPassword, createSession, SESSION_COOKIE, sessionCookieOptions } from "../../lib/auth";
import { getEnv } from "../../lib/env";
export const prerender = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const password = String(form.get("password") ?? "");
const secret = getEnv().ADMIN_PASSWORD!;
if (await checkPassword(password, secret)) {
Astro.cookies.set(SESSION_COOKIE, await createSession(secret), sessionCookieOptions);
return Astro.redirect("/admin");
}
error = "密碼唔啱。";
}
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登入後台</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<main>
<h1>登入後台</h1>
{error && <p class="err">{error}</p>}
<form method="post">
<label for="password">密碼</label>
<input id="password" name="password" type="password" autofocus required />
<div class="actions">
<button type="submit">登入</button>
</div>
</form>
</main>
</body>
</html>
<style is:global>
:root {
font-family: -apple-system, BlinkMacSystemFont, "PingFang HK", "Noto Sans HK", sans-serif;
color: #2c2c2a; background: #f7f7f5; line-height: 1.65; font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { width: 100%; max-width: 360px; padding: 32px; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 12px; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; }
label { display: block; font-size: 13px; color: #5f5e5a; margin-bottom: 6px; }
input { width: 100%; font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid rgba(0,0,0,.15); border-radius: 8px; }
input:focus { outline: 2px solid #b5d4f4; border-color: #185fa5; }
button { margin-top: 18px; width: 100%; font: inherit; font-size: 14px; padding: 9px 18px; border-radius: 8px; cursor: pointer; border: 1px solid #185fa5; background: #185fa5; color: #fff; }
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 8px; font-size: 14px; margin: 0 0 16px; }
</style>
+9
View File
@@ -0,0 +1,9 @@
import type { APIRoute } from "astro";
import { SESSION_COOKIE } from "../../lib/auth";
export const prerender = false;
export const GET: APIRoute = ({ cookies, redirect }) => {
cookies.delete(SESSION_COOKIE, { path: "/" });
return redirect("/admin/login");
};
+187
View File
@@ -0,0 +1,187 @@
---
import { and, eq, ne } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { getDb } from "../../../lib/db";
import { posts } from "../../../db/schema";
import { slugify, autoExcerpt } from "../../../lib/markdown";
import { getEnv } from "../../../lib/env";
export const prerender = false;
const { id } = Astro.params;
const isNew = id === "new";
const db = getDb(getEnv().DB);
let error = "";
const [existing] = isNew
? []
: await db.select().from(posts).where(eq(posts.id, id!)).limit(1);
if (!isNew && !existing) {
return Astro.redirect("/admin");
}
/** slug 撞咗就加 -2、-3… */
async function uniqueSlug(base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(
selfId
? and(eq(posts.slug, candidate), ne(posts.id, selfId))
: eq(posts.slug, candidate),
)
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = String(form.get("action") ?? "save");
if (action === "delete" && !isNew) {
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
const title = String(form.get("title") ?? "").trim();
const content = String(form.get("content") ?? "");
if (!title) {
error = "標題唔可以留空。";
} else {
const status = String(form.get("status") ?? "draft") === "published" ? "published" : "draft";
const rawSlug = String(form.get("slug") ?? "").trim();
const slug = await uniqueSlug(rawSlug ? slugify(rawSlug) : slugify(title), isNew ? undefined : id);
const excerpt = String(form.get("excerpt") ?? "").trim() || autoExcerpt(content);
const metaDescription = String(form.get("metaDescription") ?? "").trim() || excerpt.slice(0, 155);
const coverImage = String(form.get("coverImage") ?? "").trim() || null;
const tags = String(form.get("tags") ?? "").trim() || null;
const now = new Date();
if (isNew) {
await db.insert(posts).values({
id: crypto.randomUUID(),
slug,
title,
excerpt,
content,
coverImage,
tags,
metaDescription,
status,
publishedAt: status === "published" ? now : null,
updatedAt: now,
});
} else {
await db
.update(posts)
.set({
slug,
title,
excerpt,
content,
coverImage,
tags,
metaDescription,
status,
publishedAt: existing!.publishedAt ?? (status === "published" ? now : null),
updatedAt: now,
})
.where(eq(posts.id, id!));
}
return Astro.redirect("/admin");
}
}
const post = isNew
? {
id: "new",
slug: "",
title: "",
excerpt: "",
content: "",
coverImage: "",
tags: "",
metaDescription: "",
status: "draft" as const,
}
: existing!;
---
<AdminLayout title={isNew ? "寫新文" : `編輯:${post.title}`}>
<h1>{isNew ? "寫新文" : "編輯文章"}</h1>
{error && <p class="err">{error}</p>}
<form method="post">
<label for="title">標題</label>
<input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" />
<label for="slug">網址 slug</label>
<input id="slug" name="slug" type="text" value={post.slug} placeholder="留空會由標題自動產生" />
<label for="excerpt">摘要(列表頁顯示)</label>
<input
id="excerpt"
name="excerpt"
type="text"
value={post.excerpt ?? ""}
placeholder="留空會由內容自動產生"
/>
<label for="coverImage">封面圖片 URL(列表卡片用)</label>
<input
id="coverImage"
name="coverImage"
type="text"
value={post.coverImage ?? ""}
placeholder="https://..."
/>
<label for="tags">標籤(逗號分隔,可選)</label>
<input id="tags" name="tags" type="text" value={post.tags ?? ""} placeholder="村屋,太陽能,指南" />
<label for="metaDescription">SEO description</label>
<input
id="metaDescription"
name="metaDescription"
type="text"
value={post.metaDescription ?? ""}
placeholder="留空會用摘要,建議 120155 字"
/>
<label for="content">內容(支援 Markdown</label>
<textarea id="content" name="content" placeholder={"# 標題\n\n寫啲嘢…"}>{post.content}</textarea>
<label for="status">狀態</label>
<select id="status" name="status">
<option value="draft" selected={post.status === "draft"}>草稿</option>
<option value="published" selected={post.status === "published"}>發布</option>
</select>
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<a href="/admin"><button type="button" class="secondary">取消</button></a>
{
!isNew && (
<button
type="submit"
name="action"
value="delete"
class="danger"
onclick="return confirm('確定刪除呢篇文章?')"
>
刪除
</button>
)
}
</div>
</form>
</AdminLayout>
+60
View File
@@ -0,0 +1,60 @@
---
import AdminLayout from "../../layouts/AdminLayout.astro";
import { ALL_SETTING_KEYS, SETTINGS_GROUPS } from "../../data/settings-fields";
import { siteSettings } from "../../db/schema";
import { getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
let saved = false;
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const now = new Date();
for (const key of ALL_SETTING_KEYS) {
const value = String(form.get(key) ?? "").trim();
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
}
saved = true;
}
const settings = await getSettings(db);
---
<AdminLayout title="網站設定">
<h1>網站設定</h1>
<p class="sub">公司資料、聯絡方式、首頁文案同 SEO。儲存後前台會自動更新。</p>
{saved && <p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">✓ 已儲存</p>}
<form method="post">
{
SETTINGS_GROUPS.map((group) => (
<div class="card">
<h2>{group.title}</h2>
<div class="grid2">
{group.fields.map((f) => (
<div style={f.type === "textarea" ? "grid-column:1/-1" : ""}>
<label for={f.key}>{f.label}</label>
{f.type === "textarea" ? (
<textarea id={f.key} name={f.key} placeholder={f.placeholder}>{settings[f.key] ?? ""}</textarea>
) : (
<input id={f.key} name={f.key} type="text" value={settings[f.key] ?? ""} placeholder={f.placeholder} />
)}
</div>
))}
</div>
</div>
))
}
<div class="actions">
<button type="submit">儲存設定</button>
</div>
</form>
</AdminLayout>
+50
View File
@@ -0,0 +1,50 @@
---
import Base from "../../layouts/Base.astro";
import { BlogPost } from "../../components/site/BlogPost";
import { formatDate, getPostBySlug, getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { renderMarkdown } from "../../lib/markdown";
import { getEnv } from "../../lib/env";
export const prerender = false;
const { slug } = Astro.params;
const db = getDb(getEnv().DB);
const [post, settings] = await Promise.all([getPostBySlug(db, slug!), getSettings(db)]);
if (!post) {
Astro.response.status = 404;
}
const html = post ? renderMarkdown(post.content) : "";
const description = post?.metaDescription || post?.excerpt || "";
---
{
post ? (
<Base
title={post.title}
description={description}
ogType="article"
publishedTime={post.publishedAt}
image={post.coverImage ?? undefined}
>
<BlogPost
title={post.title}
dateLabel={formatDate(post.publishedAt)}
coverImage={post.coverImage}
html={html}
settings={settings}
client:load
/>
</Base>
) : (
<Base title="搵唔到文章" noindex={true}>
<div style="max-width:640px;margin:15vh auto;padding:0 24px;text-align:center;font-family:'Noto Sans HK',sans-serif;">
<h1 style="font-size:28px;font-weight:800;color:#0F1E1A;">搵唔到呢篇文章</h1>
<p style="color:#5B6B66;line-height:1.9;">可能篇文已經被移除,或者網址打錯咗。</p>
<p><a href="/blog" style="color:#0B8A5E;font-weight:600;">← 返去 Blog</a></p>
</div>
</Base>
)
}
+30
View File
@@ -0,0 +1,30 @@
---
import Base from "../../layouts/Base.astro";
import { BlogIndex } from "../../components/site/BlogIndex";
import { getPublishedPosts, getSettings, formatDate } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const [posts, settings] = await Promise.all([getPublishedPosts(db), getSettings(db)]);
const items = posts.map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
coverImage: p.coverImage,
dateLabel: formatDate(p.publishedAt),
}));
Astro.response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
---
<Base
title="太陽能知識庫"
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
image={settings.og_image}
>
<BlogIndex posts={items} settings={settings} client:load />
</Base>
+26
View File
@@ -0,0 +1,26 @@
---
import Base from "../layouts/Base.astro";
import { HomePage } from "../components/site/HomePage";
import { getHomeData } from "../data/content";
import { getDb } from "../lib/db";
import { getEnv } from "../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const data = await getHomeData(db);
const settings = data.settings;
Astro.response.headers.set(
"Cache-Control",
"public, s-maxage=60, stale-while-revalidate=300",
);
---
<Base
title={settings.seo_default_title || "香港村屋太陽能一站式服務"}
description={settings.seo_default_description}
image={settings.og_image}
>
<HomePage data={data} client:load />
</Base>
+14
View File
@@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ site }) => {
const origin = (site ?? new URL("https://example.com")).toString().replace(/\/$/, "");
return new Response(
`User-agent: *
Allow: /
Disallow: /admin
Sitemap: ${origin}/sitemap.xml
`,
{ headers: { "Content-Type": "text/plain; charset=utf-8" } },
);
};
+32
View File
@@ -0,0 +1,32 @@
import type { APIRoute } from "astro";
import { desc, eq } from "drizzle-orm";
import { getDb } from "../lib/db";
import { posts } from "../db/schema";
import { getEnv } from "../lib/env";
export const prerender = false;
export const GET: APIRoute = async ({ site }) => {
const origin = (site ?? new URL("https://example.com")).toString().replace(/\/$/, "");
const db = getDb(getEnv().DB);
const list = await db
.select({ slug: posts.slug, updatedAt: posts.updatedAt })
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
const urls = ["/", "/blog", ...list.map((p) => `/blog/${p.slug}`)];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) => ` <url><loc>${origin}${u}</loc></url>`).join("\n")}
</urlset>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=0, s-maxage=3600",
},
});
};
+74
View File
@@ -0,0 +1,74 @@
import { createSystem, defaultConfig, defineConfig } from "@chakra-ui/react";
// 琥珀金 —— 全站唯一彩色 accent
const brand = {
50: "#FFFBEB",
100: "#FEF3C7",
200: "#FDE68A",
300: "#FCD34D",
400: "#FBBF24",
500: "#F59E0B",
600: "#D97706",
700: "#B45309",
800: "#92400E",
900: "#78350F",
950: "#451A03",
};
const sansFont =
"'Noto Sans HK', 'PingFang HK', 'Microsoft JhengHei', system-ui, -apple-system, sans-serif";
const serifFont = `'Noto Serif HK', ${sansFont}`;
const config = defineConfig({
globalCss: {
"html": {
scrollBehavior: "smooth",
scrollPaddingTop: "88px",
},
"body": {
fontFamily: sansFont,
bg: "#FAF8F4",
color: "#1A1917",
lineHeight: 1.75,
antialiased: "true",
},
"::selection": {
bg: "brand.200",
color: "brand.900",
},
},
theme: {
tokens: {
colors: {
brand,
ink: {
DEFAULT: { value: "#1A1917" },
muted: { value: "#6E6862" },
},
},
fonts: {
heading: { value: serifFont },
body: { value: sansFont },
},
},
semanticTokens: {
colors: {
"bg.subtle": { value: "#F3EFE7" },
"fg.muted": { value: "#6E6862" },
"border.subtle": { value: "#E7E2D9" },
brand: {
solid: { value: "{colors.brand.700}" },
contrast: { value: "{colors.white}" },
fg: { value: "{colors.brand.800}" },
muted: { value: "{colors.brand.100}" },
subtle: { value: "{colors.brand.50}" },
emphasized: { value: "{colors.brand.300}" },
focusRing: { value: "{colors.brand.500}" },
border: { value: "{colors.brand.200}" },
},
},
},
},
});
export const system = createSystem(defaultConfig, config);
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "worker-configuration.d.ts", "**/*"],
"exclude": ["dist", "node_modules"],
"compilerOptions": {
"strictNullChecks": true,
"verbatimModuleSyntax": false
}
}
+15386
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "yingfung-solar",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-09-10",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
},
"observability": {
"enabled": true,
"logs": { "enabled": true }
},
"vars": {
"SITE_NAME": "盈豐太陽能"
},
// ---- id `npm run db:create` / `npm run kv:create` ----
"d1_databases": [
{
"binding": "DB",
"database_name": "yingfung-solar-db",
"database_id": "PASTE_D1_DATABASE_ID_HERE"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "PASTE_KV_NAMESPACE_ID_HERE"
}
]
}