Daniel246 commited on
Commit
031cae9
·
1 Parent(s): 8f5ca3e

v1.8.0 新增物品分類篩選、物品資訊卡、分頁瀏覽、異常值標註

Browse files
Files changed (8) hide show
  1. README.md +15 -17
  2. app.py +116 -136
  3. src/api.py +28 -6
  4. src/changelog.py +88 -0
  5. src/charts.py +61 -0
  6. src/config.py +54 -0
  7. src/display.py +247 -9
  8. src/styles.py +97 -0
README.md CHANGED
@@ -28,9 +28,11 @@ short_description: FF14 繁中服市場價格查詢與跨服比價工具
28
 
29
  ### 市場查詢
30
  - **物品搜尋** - 支援繁體中文、英文名稱、物品 ID,或直接貼上 Universalis 網址
 
 
31
  - **市場查詢** - 查看當前上架價格(NQ/HQ 分離)
32
  - **交易歷史** - 查看近期成交記錄
33
- - **價格走勢** - 互動式價格歷史圖表
34
  - **跨服比價** - 比較所有繁中服伺服器的價格差異
35
  - **雇員篩選** - 可依雇員名稱篩選上架物品
36
  - **自動刷新** - 5 秒自動更新(使用 WebSocket 緩存)
@@ -111,23 +113,30 @@ ff14_tw_market/
111
  └── src/
112
  ├── api.py # API 請求封裝
113
  ├── charts.py # 圖表繪製
114
- ├── config.py # 設定與常數
115
  ├── crafting.py # 製作利潤計算
116
  ├── display.py # 顯示格式化
117
  ├── shopping.py # 購物清單與雇員建議
118
  ├── collectables.py # 收藏品時間表
119
  ├── watchlist.py # 監看清單
120
  ├── websocket_api.py # WebSocket 連線
121
- ── ai_analysis.py # AI 分析功能
 
 
122
  ```
123
 
124
  ## 更新紀錄
125
 
 
 
 
 
 
 
126
  ### v1.7.2 (2025-01)
127
  - 即時追蹤新增「資料流狀態」圖表
128
  - 顯示各伺服器最後收到資料的時間
129
  - 以顏色標示資料新鮮度(綠/黃/橙/紅)
130
- - 讓使用者了解資料延遲來源
131
 
132
  ### v1.7.1 (2025-01)
133
  - 新增「即時追蹤」功能
@@ -139,26 +148,15 @@ ff14_tw_market/
139
  - 新增金色漸層頁首設計
140
  - 圖表配色優化
141
 
142
- ### v1.6.1 (2025-01)
143
- - 收藏品時間表新增「老主顧」NPC 資訊
144
- - 顯示對應等級範圍的老主顧名稱與位置座標
145
- - 新增「老主顧位置一覽」快速參考表
146
-
147
- ### v1.6.0 (2025-01)
148
  - 新增「收藏品時間表」功能
149
- - 顯示大地使者(採礦工、園藝工、捕魚人)收藏品採集時間
150
- - 即時 ET(艾歐澤亞時間)顯示
151
- - 資料自動快取,支援強制刷新
152
 
153
  ### v1.5.0 (2024-12)
154
  - 新增「購物助手」功能
155
- - 購物清單計算各伺服器總價
156
- - 雇員銷售建議
157
 
158
  ### v1.4.0 (2024-12)
159
  - 新增「製作利潤」功能
160
- - 遞迴計算材料成本
161
- - 賺錢排行榜
162
 
163
  ### v1.3.0 (2024-12)
164
  - 改用 WebSocket 驅動實時更新
 
28
 
29
  ### 市場查詢
30
  - **物品搜尋** - 支援繁體中文、英文名稱、物品 ID,或直接貼上 Universalis 網址
31
+ - **物品分類篩選** - 大分類(武器/防具/素材等)+ 子分類下拉選單,支援分頁瀏覽
32
+ - **物品資訊卡** - 顯示獲取方式、用途、裝備屬性、外部連結
33
  - **市場查詢** - 查看當前上架價格(NQ/HQ 分離)
34
  - **交易歷史** - 查看近期成交記錄
35
+ - **價格走勢** - 互動式價格歷史圖表,自動標註異常價格
36
  - **跨服比價** - 比較所有繁中服伺服器的價格差異
37
  - **雇員篩選** - 可依雇員名稱篩選上架物品
38
  - **自動刷新** - 5 秒自動更新(使用 WebSocket 緩存)
 
113
  └── src/
114
  ├── api.py # API 請求封裝
115
  ├── charts.py # 圖表繪製
116
+ ├── config.py # 設定與常數(含物品分類定義)
117
  ├── crafting.py # 製作利潤計算
118
  ├── display.py # 顯示格式化
119
  ├── shopping.py # 購物清單與雇員建議
120
  ├── collectables.py # 收藏品時間表
121
  ├── watchlist.py # 監看清單
122
  ├── websocket_api.py # WebSocket 連線
123
+ ── ai_analysis.py # AI 分析功能
124
+ ├── styles.py # 自訂 CSS 樣式
125
+ └── changelog.py # 更新紀錄
126
  ```
127
 
128
  ## 更新紀錄
129
 
130
+ ### v1.8.0 (2025-01)
131
+ - 新增「物品分類篩選」功能(大分類 + 子分類 + 分頁瀏覽)
132
+ - 新增「物品資訊卡」側欄(獲取方式/用途/裝備屬性/外部連結)
133
+ - 價格走勢圖新增異常值標註(⚠️ 異常低價/高價)
134
+ - 程式碼重構:CSS 和更新紀錄獨立為模組
135
+
136
  ### v1.7.2 (2025-01)
137
  - 即時追蹤新增「資料流狀態」圖表
138
  - 顯示各伺服器最後收到資料的時間
139
  - 以顏色標示資料新鮮度(綠/黃/橙/紅)
 
140
 
141
  ### v1.7.1 (2025-01)
142
  - 新增「即時追蹤」功能
 
148
  - 新增金色漸層頁首設計
149
  - 圖表配色優化
150
 
151
+ ### v1.6.x (2025-01)
 
 
 
 
 
152
  - 新增「收藏品時間表」功能
153
+ - 新增「老主顧」NPC 資訊
 
 
154
 
155
  ### v1.5.0 (2024-12)
156
  - 新增「購物助手」功能
 
 
157
 
158
  ### v1.4.0 (2024-12)
159
  - 新增「製作利潤」功能
 
 
160
 
161
  ### v1.3.0 (2024-12)
162
  - 改用 WebSocket 驅動實時更新
app.py CHANGED
@@ -6,63 +6,9 @@ import gradio as gr
6
  GRADIO_VERSION = int(gr.__version__.split(".")[0])
7
  USE_BROWSER_STATE = GRADIO_VERSION >= 5
8
 
9
- from src.config import POPULAR_ITEMS, WORLD_NAMES
10
-
11
- # 自訂 CSS 樣式 - 僅頁首裝飾
12
- CUSTOM_CSS = """
13
- /* 頁首樣式 */
14
- .header-box {
15
- background: linear-gradient(135deg, #b8860b 0%, #daa520 50%, #b8860b 100%);
16
- border-radius: 12px;
17
- padding: 20px 24px;
18
- margin-bottom: 16px;
19
- }
20
-
21
- .header-box h1 {
22
- color: #1a1a2e !important;
23
- margin: 0 0 8px 0 !important;
24
- }
25
-
26
- .header-box p {
27
- color: #2c2c2c !important;
28
- margin: 4px 0 !important;
29
- }
30
-
31
- .header-box a {
32
- color: #1a1a2e !important;
33
- font-weight: 700;
34
- text-decoration: underline;
35
- }
36
-
37
- /* 伺服器標籤 */
38
- .server-tags {
39
- display: flex;
40
- flex-wrap: wrap;
41
- gap: 6px;
42
- margin-top: 10px;
43
- }
44
-
45
- .server-tag {
46
- background: rgba(0,0,0,0.2);
47
- color: #1a1a2e;
48
- padding: 4px 10px;
49
- border-radius: 12px;
50
- font-size: 0.8em;
51
- font-weight: 600;
52
- }
53
-
54
- /* 狀態標籤 */
55
- .status-badge {
56
- display: inline-block;
57
- background: #27ae60;
58
- color: #ffffff;
59
- padding: 6px 12px;
60
- border-radius: 20px;
61
- font-size: 0.85em;
62
- margin-top: 12px;
63
- font-weight: 500;
64
- }
65
- """
66
  from src.display import (
67
  display_item_market,
68
  display_market_activity,
@@ -170,6 +116,22 @@ def create_app() -> gr.Blocks:
170
  return app
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def _build_market_tab() -> None:
174
  """建立市場查詢頁籤."""
175
  with gr.TabItem("市場查詢"):
@@ -184,6 +146,29 @@ def _build_market_tab() -> None:
184
  placeholder="繁體中文、英文名稱、物品 ID 或 Universalis 網址",
185
  lines=1,
186
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  search_status = gr.Markdown(
188
  "顯示常用物品,或輸入物品名稱/ID搜尋"
189
  )
@@ -196,6 +181,16 @@ def _build_market_tab() -> None:
196
  interactive=True,
197
  )
198
 
 
 
 
 
 
 
 
 
 
 
199
  with gr.Column(scale=1):
200
  world_select = gr.Dropdown(
201
  label="選擇伺服器",
@@ -219,7 +214,11 @@ def _build_market_tab() -> None:
219
  value=False,
220
  )
221
 
222
- item_info = gr.Markdown("")
 
 
 
 
223
 
224
  with gr.Row():
225
  with gr.Column():
@@ -253,18 +252,70 @@ def _build_market_tab() -> None:
253
  # 自動刷新用的計時器 (5秒,使用 WebSocket 緩存)
254
  timer = gr.Timer(value=5, active=False)
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # 事件綁定
 
 
 
 
 
 
 
 
257
  search_input.change(
258
- fn=search_and_display,
259
- inputs=[search_input],
260
- outputs=[item_dropdown, search_status, item_info],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  )
262
 
263
  search_btn.click(
264
  fn=display_item_market,
265
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
266
  outputs=[
267
- item_info, listings_table, history_table,
268
  price_chart, comparison_table, comparison_chart,
269
  ],
270
  )
@@ -273,7 +324,7 @@ def _build_market_tab() -> None:
273
  fn=display_item_market,
274
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
275
  outputs=[
276
- item_info, listings_table, history_table,
277
  price_chart, comparison_table, comparison_chart,
278
  ],
279
  )
@@ -290,7 +341,7 @@ def _build_market_tab() -> None:
290
  fn=display_item_market,
291
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
292
  outputs=[
293
- item_info, listings_table, history_table,
294
  price_chart, comparison_table, comparison_chart,
295
  ],
296
  )
@@ -1144,78 +1195,7 @@ def _build_ai_tab() -> None:
1144
  def _build_changelog_tab() -> None:
1145
  """建立更新紀錄頁籤."""
1146
  with gr.TabItem("更新紀錄"):
1147
- gr.Markdown("""
1148
- ### 📝 更新紀錄
1149
-
1150
- ### v1.7.2 (2025-01)
1151
- - 即時追蹤新增「資料流狀態」圖表
1152
- - 顯示各伺服器最後收到資料的時間
1153
- - 以顏色標示資料新鮮度(綠/黃/橙/紅)
1154
- - 讓使用者了解資料延遲來源
1155
-
1156
- ### v1.7.1 (2025-01)
1157
- - 新增「即時追蹤」功能
1158
- - 顯示繁中服正在發生的市場交易(上架、售出)
1159
- - 數據由 Universalis WebSocket 即時推送
1160
- - 支援自動刷新(3秒)
1161
-
1162
- ### v1.7.0 (2025-01)
1163
- - 介面美化:採用 Gradio Soft 主題
1164
- - 新增金色漸層頁首設計
1165
- - 伺服器標籤視覺化呈現
1166
- - 圖表配色優化(NQ 藍灰色、HQ 琥珀金色)
1167
-
1168
- ### v1.6.1 (2025-01)
1169
- - 收藏品時間表新增「老主顧」NPC 資訊
1170
- - 顯示對應等級範圍的老主顧名稱與位置座標
1171
- - 新增「老主顧位置一覽」快速參考表
1172
-
1173
- ### v1.6.0 (2025-01)
1174
- - 新增「收藏品時間表」功能
1175
- - 顯示大地使者(採礦工、園藝工、捕魚人)收藏品採集時間
1176
- - 即時 ET(艾歐澤亞時間)顯示
1177
- - 顯示目前可採集和即將出現的收藏品
1178
- - 包含採集地點、座標、工票獎勵
1179
- - 資料自動快取,支援強制刷新
1180
-
1181
- ### v1.5.0 (2024-12)
1182
- - 新增「購物助手」功能
1183
- - 購物清單:輸入多個物品,計算各伺服器總價,找最便宜的購買方案
1184
- - 雇員銷售建議:分析銷售速度與價格,推薦值得上架的物品
1185
-
1186
- ### v1.4.0 (2024-12)
1187
- - 新增「製作利潤」功能
1188
- - 計算製作成本 vs 市場售價
1189
- - 遞迴計算材料成本(比較買 vs 自己做)
1190
- - 賺錢排行榜:動態掃描最近交易物品
1191
- - 職業篩選(木工、鍛冶、裁縫、烹調等)
1192
-
1193
- ### v1.3.0 (2024-12)
1194
- - 改用 WebSocket 驅動實時更新
1195
- - 首次查詢用 REST API,之後用 WebSocket 緩存
1196
- - 自動刷新改為 5 秒(使用緩存時幾乎無延遲)
1197
- - 物品資訊與市場數據並行請求
1198
-
1199
- ### v1.2.0 (2024-12)
1200
- - 新增 AI 分析功能
1201
- - 支援跨服套利判斷
1202
- - 手機版面優化
1203
-
1204
- ### v1.1.0 (2024-12)
1205
- - 新增監看清單功能
1206
- - 支援設定目標價格提醒
1207
- - 資料儲存於瀏覽器 LocalStorage
1208
-
1209
- ### v1.0.0 (2024-12)
1210
- - 首次發布
1211
- - 支援繁體中文搜尋物品
1212
- - 市場價格查詢、交易紀錄
1213
- - 跨伺服器比價
1214
- - 稅率資訊、上傳統計
1215
-
1216
- ---
1217
- 資料來源: [Universalis API](https://universalis.app/)
1218
- """)
1219
 
1220
 
1221
  if __name__ == "__main__":
 
6
  GRADIO_VERSION = int(gr.__version__.split(".")[0])
7
  USE_BROWSER_STATE = GRADIO_VERSION >= 5
8
 
9
+ from src.config import POPULAR_ITEMS, WORLD_NAMES, ITEM_SUB_CATEGORIES
10
+ from src.styles import CUSTOM_CSS
11
+ from src.changelog import CHANGELOG_MD
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from src.display import (
13
  display_item_market,
14
  display_market_activity,
 
116
  return app
117
 
118
 
119
+ def update_sub_categories(main_cat_id: int):
120
+ """根據大分類更新子分類選項."""
121
+ if main_cat_id == 0 or main_cat_id not in ITEM_SUB_CATEGORIES:
122
+ return gr.update(choices=[("全部子分類", 0)], value=0, visible=False)
123
+
124
+ sub_cats = ITEM_SUB_CATEGORIES[main_cat_id]
125
+ choices = [("全部子分類", 0)] + [(name, cid) for cid, name in sub_cats.items()]
126
+ return gr.update(choices=choices, value=0, visible=True)
127
+
128
+
129
+ def get_effective_category(sub_cat: int) -> int:
130
+ """取得實際要查詢的分類 ID."""
131
+ # 如果有選擇子分類,使用子分類;否則不加分類限制
132
+ return sub_cat if sub_cat > 0 else 0
133
+
134
+
135
  def _build_market_tab() -> None:
136
  """建立市場查詢頁籤."""
137
  with gr.TabItem("市場查詢"):
 
146
  placeholder="繁體中文、英文名稱、物品 ID 或 Universalis 網址",
147
  lines=1,
148
  )
149
+
150
+ # 分類篩選區塊
151
+ gr.Markdown("#### 物品分類篩選")
152
+ with gr.Group():
153
+ main_category = gr.Radio(
154
+ label="大分類",
155
+ choices=[
156
+ ("全部", 0), ("武器", 1), ("製作工具", 2), ("採集工具", 3),
157
+ ("防具", 4), ("飾品", 5), ("藥品食品", 6), ("素材", 7), ("其他", 8),
158
+ ],
159
+ value=0,
160
+ interactive=True,
161
+ elem_classes=["category-radio"],
162
+ )
163
+
164
+ sub_category = gr.Dropdown(
165
+ label="子分類",
166
+ choices=[("全部子分類", 0)],
167
+ value=0,
168
+ interactive=True,
169
+ visible=False,
170
+ )
171
+
172
  search_status = gr.Markdown(
173
  "顯示常用物品,或輸入物品名稱/ID搜尋"
174
  )
 
181
  interactive=True,
182
  )
183
 
184
+ # 分頁控制
185
+ with gr.Row():
186
+ prev_page_btn = gr.Button("◀ 上一頁", size="sm")
187
+ page_info = gr.Markdown("")
188
+ next_page_btn = gr.Button("下一頁 ▶", size="sm")
189
+
190
+ # 分頁狀態
191
+ current_page_state = gr.State(value=1)
192
+ total_pages_state = gr.State(value=1)
193
+
194
  with gr.Column(scale=1):
195
  world_select = gr.Dropdown(
196
  label="選擇伺服器",
 
214
  value=False,
215
  )
216
 
217
+ with gr.Row():
218
+ with gr.Column(scale=1):
219
+ item_info = gr.Markdown("")
220
+ with gr.Column(scale=1):
221
+ item_card = gr.Markdown("")
222
 
223
  with gr.Row():
224
  with gr.Column():
 
252
  # 自動刷新用的計時器 (5秒,使用 WebSocket 緩存)
253
  timer = gr.Timer(value=5, active=False)
254
 
255
+ # 輔助函數:根據分類搜尋(含分頁)
256
+ def search_with_category(query, sub_cat, page=1):
257
+ category = get_effective_category(sub_cat)
258
+ dropdown, status, info, cur_page, total_pages = search_and_display(query, category, page)
259
+
260
+ # 更新分頁資訊顯示
261
+ if total_pages > 1:
262
+ page_text = f"第 {cur_page} / {total_pages} 頁"
263
+ else:
264
+ page_text = ""
265
+
266
+ return dropdown, status, info, cur_page, total_pages, page_text
267
+
268
+ def go_prev_page(query, sub_cat, current_page, _total_pages):
269
+ if current_page > 1:
270
+ return search_with_category(query, sub_cat, current_page - 1)
271
+ return search_with_category(query, sub_cat, current_page)
272
+
273
+ def go_next_page(query, sub_cat, current_page, total_pages):
274
+ if current_page < total_pages:
275
+ return search_with_category(query, sub_cat, current_page + 1)
276
+ return search_with_category(query, sub_cat, current_page)
277
+
278
  # 事件綁定
279
+ # 大分類變更 -> 更新子分類選項
280
+ main_category.change(
281
+ fn=update_sub_categories,
282
+ inputs=[main_category],
283
+ outputs=[sub_category],
284
+ )
285
+
286
+ # 搜尋輸入變更時,帶入分類參數(重置到第一頁)
287
  search_input.change(
288
+ fn=search_with_category,
289
+ inputs=[search_input, sub_category],
290
+ outputs=[item_dropdown, search_status, item_info, current_page_state, total_pages_state, page_info],
291
+ )
292
+
293
+ # 子分類變更 -> 重新搜尋(重置到第一頁)
294
+ sub_category.change(
295
+ fn=search_with_category,
296
+ inputs=[search_input, sub_category],
297
+ outputs=[item_dropdown, search_status, item_info, current_page_state, total_pages_state, page_info],
298
+ )
299
+
300
+ # 上一頁按鈕
301
+ prev_page_btn.click(
302
+ fn=go_prev_page,
303
+ inputs=[search_input, sub_category, current_page_state, total_pages_state],
304
+ outputs=[item_dropdown, search_status, item_info, current_page_state, total_pages_state, page_info],
305
+ )
306
+
307
+ # 下一頁按鈕
308
+ next_page_btn.click(
309
+ fn=go_next_page,
310
+ inputs=[search_input, sub_category, current_page_state, total_pages_state],
311
+ outputs=[item_dropdown, search_status, item_info, current_page_state, total_pages_state, page_info],
312
  )
313
 
314
  search_btn.click(
315
  fn=display_item_market,
316
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
317
  outputs=[
318
+ item_info, item_card, listings_table, history_table,
319
  price_chart, comparison_table, comparison_chart,
320
  ],
321
  )
 
324
  fn=display_item_market,
325
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
326
  outputs=[
327
+ item_info, item_card, listings_table, history_table,
328
  price_chart, comparison_table, comparison_chart,
329
  ],
330
  )
 
341
  fn=display_item_market,
342
  inputs=[item_dropdown, world_select, quality_select, retainer_filter],
343
  outputs=[
344
+ item_info, item_card, listings_table, history_table,
345
  price_chart, comparison_table, comparison_chart,
346
  ],
347
  )
 
1195
  def _build_changelog_tab() -> None:
1196
  """建立更新紀錄頁籤."""
1197
  with gr.TabItem("更新紀錄"):
1198
+ gr.Markdown(CHANGELOG_MD)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1199
 
1200
 
1201
  if __name__ == "__main__":
src/api.py CHANGED
@@ -44,18 +44,24 @@ def _extract_item_id(query: str) -> int:
44
  return 0
45
 
46
 
47
- def search_items(query: str, limit: int = 20) -> list:
48
  """搜尋物品 - 支援繁體中文、簡體中文、英文.
49
 
50
  Args:
51
  query: 搜尋關鍵字、物品 ID 或 Universalis 網址
52
- limit: 結果數量限制
 
 
53
 
54
  Returns:
55
- 物品列表,每個物品包含 id, name, icon, level
 
 
56
  """
 
 
57
  if not query:
58
- return []
59
 
60
  query = query.strip()
61
  results = []
@@ -71,22 +77,38 @@ def search_items(query: str, limit: int = 20) -> list:
71
  "icon": "",
72
  "level": item_info.get("LevelItem", 0),
73
  })
74
- return results
75
 
76
  # 2. 將繁體中文轉換為簡體中文(Cafemaker 使用簡體)
77
  query_simplified = _t2s_converter.convert(query)
78
 
79
  # 3. 使用 Cafemaker 搜尋(支援中文)
 
80
  try:
81
  url = f"{CAFEMAKER_BASE}/search"
82
  params = {
83
  "string": query_simplified,
84
  "indexes": "Item",
85
  "limit": limit,
 
86
  }
 
 
 
 
 
87
  response = requests.get(url, params=params, timeout=API_TIMEOUT)
88
  if response.status_code == 200:
89
  data = response.json()
 
 
 
 
 
 
 
 
 
90
  for item in data.get("Results", []):
91
  if not any(r["id"] == item.get("ID") for r in results):
92
  # 將簡體中文名稱轉換為繁體中文
@@ -100,7 +122,7 @@ def search_items(query: str, limit: int = 20) -> list:
100
  except requests.RequestException as e:
101
  print(f"Cafemaker 搜尋錯誤: {e}")
102
 
103
- return results[:limit]
104
 
105
 
106
  def get_item_info(item_id: int) -> dict:
 
44
  return 0
45
 
46
 
47
+ def search_items(query: str, limit: int = 20, category: int = 0, page: int = 1) -> dict:
48
  """搜尋物品 - 支援繁體中文、簡體中文、英文.
49
 
50
  Args:
51
  query: 搜尋關鍵字、物品 ID 或 Universalis 網址
52
+ limit: 每頁結果數量限制
53
+ category: 物品分類 ID (ItemSearchCategory),0 表示全部
54
+ page: 頁碼(從 1 開始)
55
 
56
  Returns:
57
+ 包含 items, pagination 的字典:
58
+ - items: 物品列表,每個物品包含 id, name, icon, level
59
+ - pagination: 分頁資訊 (page, page_total, results_total)
60
  """
61
+ empty_result = {"items": [], "pagination": {"page": 1, "page_total": 1, "results_total": 0}}
62
+
63
  if not query:
64
+ return empty_result
65
 
66
  query = query.strip()
67
  results = []
 
77
  "icon": "",
78
  "level": item_info.get("LevelItem", 0),
79
  })
80
+ return {"items": results, "pagination": {"page": 1, "page_total": 1, "results_total": 1}}
81
 
82
  # 2. 將繁體中文轉換為簡體中文(Cafemaker 使用簡體)
83
  query_simplified = _t2s_converter.convert(query)
84
 
85
  # 3. 使用 Cafemaker 搜尋(支援中文)
86
+ pagination = {"page": page, "page_total": 1, "results_total": 0}
87
  try:
88
  url = f"{CAFEMAKER_BASE}/search"
89
  params = {
90
  "string": query_simplified,
91
  "indexes": "Item",
92
  "limit": limit,
93
+ "page": page,
94
  }
95
+
96
+ # 如果有指定分類,加入篩選條件
97
+ if category > 0:
98
+ params["filters"] = f"ItemSearchCategory.ID={category}"
99
+
100
  response = requests.get(url, params=params, timeout=API_TIMEOUT)
101
  if response.status_code == 200:
102
  data = response.json()
103
+
104
+ # 取得分頁資訊
105
+ pag_info = data.get("Pagination", {})
106
+ pagination = {
107
+ "page": pag_info.get("Page", 1),
108
+ "page_total": pag_info.get("PageTotal", 1),
109
+ "results_total": pag_info.get("ResultsTotal", 0),
110
+ }
111
+
112
  for item in data.get("Results", []):
113
  if not any(r["id"] == item.get("ID") for r in results):
114
  # 將簡體中文名稱轉換為繁體中文
 
122
  except requests.RequestException as e:
123
  print(f"Cafemaker 搜尋錯誤: {e}")
124
 
125
+ return {"items": results, "pagination": pagination}
126
 
127
 
128
  def get_item_info(item_id: int) -> dict:
src/changelog.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """更新紀錄."""
2
+
3
+ CHANGELOG_MD = """
4
+ ### 📝 更新紀錄
5
+
6
+ ### v1.8.0 (2025-01)
7
+ - 新增「物品分類篩選」功能
8
+ - 大分類:武器、防具、飾品、素材等 8 大類
9
+ - 子分類:根據大分類動態更新選項
10
+ - 分頁瀏覽:上一頁/下一頁按鈕
11
+ - 新增「物品資訊卡」側欄
12
+ - 顯示獲取方式(製作/採集/商店/雇員探險)
13
+ - 顯示用途(製作材料/部隊工房/理符/軍票)
14
+ - 裝備屬性(職業/傷害/防禦/屬性加成)
15
+ - 外部連結(Universalis/Teamcraft/Garland Tools)
16
+ - 價格走勢圖新增異常值標註
17
+ - 自動檢測異常低價/高價
18
+ - 以 ⚠️ 標記並保留原始數據
19
+
20
+ ### v1.7.2 (2025-01)
21
+ - 即時追蹤新增「資料流狀態」圖表
22
+ - 顯示各伺服器最後收到資料的時間
23
+ - 以顏色標示資料新鮮度(綠/黃/橙/紅)
24
+ - 讓使用者了解資料延遲來源
25
+
26
+ ### v1.7.1 (2025-01)
27
+ - 新增「即時追蹤」功能
28
+ - 顯示繁中服正在發生的市場交易(上架、售出)
29
+ - 數據由 Universalis WebSocket 即時推送
30
+ - 支援自動刷新(3秒)
31
+
32
+ ### v1.7.0 (2025-01)
33
+ - 介面美化:採用 Gradio Soft 主題
34
+ - 新增金色漸層頁首設計
35
+ - 伺服器標籤視覺化呈現
36
+ - 圖表配色優化(NQ 藍灰色、HQ 琥珀金色)
37
+
38
+ ### v1.6.1 (2025-01)
39
+ - 收藏品時間表新增「老主顧」NPC 資訊
40
+ - 顯示對應等級範圍的老主顧名稱與位置座標
41
+ - 新增「老主顧位置一覽」快速參考表
42
+
43
+ ### v1.6.0 (2025-01)
44
+ - 新增「收藏品時間表」功能
45
+ - 顯示大地使者(採礦工、園藝工、捕魚人)收藏品採集時間
46
+ - 即時 ET(艾歐澤亞時間)顯示
47
+ - 顯示目前可採集和即將出現的收藏品
48
+ - 包含採集地點、座標、工票獎勵
49
+ - 資料自動快取,支援強制刷新
50
+
51
+ ### v1.5.0 (2024-12)
52
+ - 新增「購物助手」功能
53
+ - 購物清單:輸入多個物品,計算各伺服器總價,找最便宜的購買方案
54
+ - 雇員銷售建議:分析銷售速度與價格,推薦值得上架的物品
55
+
56
+ ### v1.4.0 (2024-12)
57
+ - 新增「製作利潤」功能
58
+ - 計算製作成本 vs 市場售價
59
+ - 遞迴計算材料成本(比較買 vs 自己做)
60
+ - 賺錢排行榜:動態掃描最近交易物品
61
+ - 職業篩選(木工、鍛冶、裁縫、烹調等)
62
+
63
+ ### v1.3.0 (2024-12)
64
+ - 改用 WebSocket 驅動實時更新
65
+ - 首次查詢用 REST API,之後用 WebSocket 緩存
66
+ - 自動刷新改為 5 秒(使用緩存時幾乎無延遲)
67
+ - 物品資訊與市場數據並行請求
68
+
69
+ ### v1.2.0 (2024-12)
70
+ - 新增 AI 分析功能
71
+ - 支援跨服套利判斷
72
+ - 手機版面優化
73
+
74
+ ### v1.1.0 (2024-12)
75
+ - 新增監看清單功能
76
+ - 支援設定目標價格提醒
77
+ - 資料儲存於瀏覽器 LocalStorage
78
+
79
+ ### v1.0.0 (2024-12)
80
+ - 首次發布
81
+ - 支援繁體中文搜尋物品
82
+ - 市場價格查詢、交易紀錄
83
+ - 跨伺服器比價
84
+ - 稅率資訊、上傳統計
85
+
86
+ ---
87
+ 資料來源: [Universalis API](https://universalis.app/)
88
+ """
src/charts.py CHANGED
@@ -71,6 +71,27 @@ def _normalize_timestamp(timestamp: int) -> int:
71
  return timestamp
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def create_price_chart(market_data: dict, item_name: str) -> go.Figure:
75
  """建立價格歷史圖表.
76
 
@@ -114,8 +135,15 @@ def create_price_chart(market_data: dict, item_name: str) -> go.Figure:
114
  if not e.get("hq")
115
  ]
116
 
 
 
 
 
117
  fig = go.Figure()
118
 
 
 
 
119
  if nq_data:
120
  nq_times, nq_prices = zip(*sorted(nq_data))
121
  fig.add_trace(go.Scatter(
@@ -134,6 +162,10 @@ def create_price_chart(market_data: dict, item_name: str) -> go.Figure:
134
  },
135
  hovertemplate="<b>NQ</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>",
136
  ))
 
 
 
 
137
 
138
  if hq_data:
139
  hq_times, hq_prices = zip(*sorted(hq_data))
@@ -154,6 +186,35 @@ def create_price_chart(market_data: dict, item_name: str) -> go.Figure:
154
  },
155
  hovertemplate="<b>HQ ★</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>",
156
  ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  fig.update_layout(
159
  **CHART_LAYOUT,
 
71
  return timestamp
72
 
73
 
74
+ def _detect_outliers(prices: list) -> tuple:
75
+ """使用 IQR 方法檢測異常值.
76
+
77
+ Returns:
78
+ (lower_bound, upper_bound) 正常價格範圍
79
+ """
80
+ if len(prices) < 4:
81
+ return (0, float('inf'))
82
+
83
+ sorted_prices = sorted(prices)
84
+ n = len(sorted_prices)
85
+ q1 = sorted_prices[n // 4]
86
+ q3 = sorted_prices[3 * n // 4]
87
+ iqr = q3 - q1
88
+
89
+ lower_bound = q1 - 1.5 * iqr
90
+ upper_bound = q3 + 1.5 * iqr
91
+
92
+ return (max(0, lower_bound), upper_bound)
93
+
94
+
95
  def create_price_chart(market_data: dict, item_name: str) -> go.Figure:
96
  """建立價格歷史圖表.
97
 
 
135
  if not e.get("hq")
136
  ]
137
 
138
+ # 檢測異常值
139
+ all_prices = [e["pricePerUnit"] for e in entries]
140
+ lower_bound, upper_bound = _detect_outliers(all_prices)
141
+
142
  fig = go.Figure()
143
 
144
+ # 收集異常值用於標註
145
+ outliers = []
146
+
147
  if nq_data:
148
  nq_times, nq_prices = zip(*sorted(nq_data))
149
  fig.add_trace(go.Scatter(
 
162
  },
163
  hovertemplate="<b>NQ</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>",
164
  ))
165
+ # 找出 NQ 異常值
166
+ for t, p in zip(nq_times, nq_prices):
167
+ if p < lower_bound or p > upper_bound:
168
+ outliers.append((t, p, "NQ"))
169
 
170
  if hq_data:
171
  hq_times, hq_prices = zip(*sorted(hq_data))
 
186
  },
187
  hovertemplate="<b>HQ ★</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>",
188
  ))
189
+ # 找出 HQ 異常值
190
+ for t, p in zip(hq_times, hq_prices):
191
+ if p < lower_bound or p > upper_bound:
192
+ outliers.append((t, p, "HQ"))
193
+
194
+ # 標註異常值
195
+ for t, p, quality in outliers:
196
+ if p < lower_bound:
197
+ label = "異常低價"
198
+ color = "#ff6b6b"
199
+ else:
200
+ label = "異常高價"
201
+ color = "#ffd93d"
202
+
203
+ fig.add_annotation(
204
+ x=t,
205
+ y=p,
206
+ text=f"⚠️ {label}",
207
+ showarrow=True,
208
+ arrowhead=2,
209
+ arrowsize=1,
210
+ arrowwidth=2,
211
+ arrowcolor=color,
212
+ font={"color": color, "size": 10},
213
+ bgcolor="rgba(0,0,0,0.7)",
214
+ bordercolor=color,
215
+ borderwidth=1,
216
+ borderpad=3,
217
+ )
218
 
219
  fig.update_layout(
220
  **CHART_LAYOUT,
src/config.py CHANGED
@@ -58,3 +58,57 @@ POPULAR_ITEMS = {
58
  # API 請求超時時間(秒)
59
  API_TIMEOUT = 10
60
  MARKET_API_TIMEOUT = 15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  # API 請求超時時間(秒)
59
  API_TIMEOUT = 10
60
  MARKET_API_TIMEOUT = 15
61
+
62
+ # 物品搜尋分類 (ItemSearchCategory)
63
+ # 大分類 ID
64
+ ITEM_MAIN_CATEGORIES = {
65
+ 0: "全部",
66
+ 1: "武器",
67
+ 2: "製作工具",
68
+ 3: "採集工具",
69
+ 4: "防具",
70
+ 5: "飾品",
71
+ 6: "藥品食品",
72
+ 7: "素材",
73
+ 8: "其他",
74
+ }
75
+
76
+ # 子分類對應(大分類 ID -> 子分類列表)
77
+ ITEM_SUB_CATEGORIES = {
78
+ 1: { # 武器
79
+ 9: "格鬥武器", 10: "劍", 11: "斧", 12: "弓", 13: "長槍",
80
+ 14: "咒杖", 15: "幻杖", 16: "魔導書", 17: "盾", 18: "投擲武器",
81
+ 73: "雙劍", 76: "雙手劍", 77: "火槍", 78: "天球儀",
82
+ 83: "武士刀", 84: "刺劍", 85: "魔導書(學者)", 86: "槍刃",
83
+ 88: "雙手鐮刀", 89: "賢具", 91: "蝰蛇對劍", 92: "畫筆",
84
+ },
85
+ 2: { # 製作工具
86
+ 19: "刻木工具", 20: "鍛鐵工具", 21: "鑄甲工具", 22: "雕金工具",
87
+ 23: "製革工具", 24: "裁衣工具", 25: "煉金工具", 26: "烹調工具",
88
+ },
89
+ 3: { # 採集工具
90
+ 27: "採礦工具", 28: "園藝工具", 29: "捕魚用具", 30: "釣餌",
91
+ },
92
+ 4: { # 防具
93
+ 31: "頭部防具", 32: "內衣", 33: "身體防具", 34: "內褲",
94
+ 35: "腿部防具", 36: "手部防具", 37: "腳部防具", 38: "腰部防具",
95
+ },
96
+ 5: { # 飾品
97
+ 39: "項鏈", 40: "耳飾", 41: "手鐲", 42: "戒指",
98
+ },
99
+ 6: { # 藥品食品
100
+ 43: "藥品", 44: "食材", 45: "食品", 46: "水產品",
101
+ },
102
+ 7: { # 素材
103
+ 47: "石材", 48: "金屬", 49: "木材", 50: "布料",
104
+ 51: "皮革", 52: "骨材", 53: "煉金原料", 54: "染料", 55: "部件",
105
+ },
106
+ 8: { # 其他
107
+ 56: "一般傢俱", 57: "魔晶石", 58: "水晶", 59: "觸媒", 60: "雜貨",
108
+ 61: "靈魂水晶", 62: "箭", 65: "室外建材", 66: "室內建材",
109
+ 67: "庭具", 68: "椅子睡床", 69: "桌台", 70: "桌上",
110
+ 71: "壁掛", 72: "地毯", 74: "雜貨(季節)", 75: "寵物",
111
+ 79: "飛空艇/潛水艇部件", 80: "管弦樂琴", 81: "栽培用品", 82: "繪畫作品",
112
+ 90: "雜貨(學習/收錄)",
113
+ },
114
+ }
src/display.py CHANGED
@@ -38,36 +38,61 @@ from .utils import (
38
  )
39
 
40
 
41
- def search_and_display(query: str) -> tuple:
42
  """搜尋並顯示結果.
43
 
44
  Args:
45
  query: 搜尋關鍵字
 
 
46
 
47
  Returns:
48
- (下拉選單更新, 狀態訊息, None)
49
  """
50
- if not query:
 
51
  choices = [(name, item_id) for name, item_id in POPULAR_ITEMS.items()]
52
  return (
53
  gr.update(choices=choices, value=None),
54
  "顯示常用物品,或輸入物品名稱/ID搜尋",
55
  None,
 
 
56
  )
57
 
58
- results = search_items(query)
59
- if not results:
 
 
 
 
 
 
 
 
 
60
  return (
61
  gr.update(choices=[], value=None),
62
  "找不到符合的物品。提示:可直接輸入物品 ID",
63
  None,
 
 
64
  )
65
 
66
- choices = [(f"{r['name']} (ID:{r['id']})", r["id"]) for r in results]
 
 
 
 
 
 
 
67
  return (
68
  gr.update(choices=choices, value=None),
69
- f"找到 {len(results)} 個結果",
70
  None,
 
 
71
  )
72
 
73
 
@@ -86,13 +111,13 @@ def display_item_market(
86
  retainer_filter: 雇員名稱篩選
87
 
88
  Returns:
89
- (物品資訊, 上架列表, 交易歷史, 價格圖表, 比價表格, 比價圖表)
90
  """
91
  empty_df = pd.DataFrame()
92
  empty_fig = go.Figure()
93
 
94
  if not item_selection:
95
- return "", empty_df, empty_df, empty_fig, empty_df, empty_fig
96
 
97
  item_id = item_selection
98
  world_query = (
@@ -136,6 +161,7 @@ def display_item_market(
136
  if not market_data:
137
  return (
138
  f"## {item_name}\n\n無法取得市場數據",
 
139
  empty_df,
140
  empty_df,
141
  empty_fig,
@@ -193,10 +219,222 @@ def display_item_market(
193
  | 日銷售量 | {sale_velocity:.1f} |
194
 
195
  *最後更新: {last_update}*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  """
197
 
198
  return (
199
  info_text,
 
200
  listings_df,
201
  history_df,
202
  price_chart,
 
38
  )
39
 
40
 
41
+ def search_and_display(query: str, category: int = 0, page: int = 1) -> tuple:
42
  """搜尋並顯示結果.
43
 
44
  Args:
45
  query: 搜尋關鍵字
46
+ category: 物品分類 ID (ItemSearchCategory),0 表示全部
47
+ page: 頁碼
48
 
49
  Returns:
50
+ (下拉選單更新, 狀態訊息, None, 頁碼, 總頁數)
51
  """
52
+ # 如果沒有搜尋關鍵字且沒有選擇分類,顯示常用物品
53
+ if not query and category == 0:
54
  choices = [(name, item_id) for name, item_id in POPULAR_ITEMS.items()]
55
  return (
56
  gr.update(choices=choices, value=None),
57
  "顯示常用物品,或輸入物品名稱/ID搜尋",
58
  None,
59
+ 1, # 當前頁
60
+ 1, # 總頁數
61
  )
62
 
63
+ # 有分類時,即使沒有關鍵字也進行搜尋(使用空白作為萬用搜尋)
64
+ search_query = query if query else " "
65
+ result = search_items(search_query, category=category, page=page)
66
+ items = result.get("items", [])
67
+ pagination = result.get("pagination", {})
68
+
69
+ current_page = pagination.get("page", 1)
70
+ total_pages = pagination.get("page_total", 1)
71
+ total_results = pagination.get("results_total", 0)
72
+
73
+ if not items:
74
  return (
75
  gr.update(choices=[], value=None),
76
  "找不到符合的物品。提示:可直接輸入物品 ID",
77
  None,
78
+ 1,
79
+ 1,
80
  )
81
 
82
+ choices = [(f"{r['name']} (ID:{r['id']})", r["id"]) for r in items]
83
+
84
+ # 狀態訊息顯示分頁資訊
85
+ if total_pages > 1:
86
+ status = f"共 {total_results} 個結果,第 {current_page}/{total_pages} 頁"
87
+ else:
88
+ status = f"找到 {total_results} 個結果"
89
+
90
  return (
91
  gr.update(choices=choices, value=None),
92
+ status,
93
  None,
94
+ current_page,
95
+ total_pages,
96
  )
97
 
98
 
 
111
  retainer_filter: 雇員名稱篩選
112
 
113
  Returns:
114
+ (物品資訊, 物品卡片, 上架列表, 交易歷史, 價格圖表, 比價表格, 比價圖表)
115
  """
116
  empty_df = pd.DataFrame()
117
  empty_fig = go.Figure()
118
 
119
  if not item_selection:
120
+ return "", "", empty_df, empty_df, empty_fig, empty_df, empty_fig
121
 
122
  item_id = item_selection
123
  world_query = (
 
161
  if not market_data:
162
  return (
163
  f"## {item_name}\n\n無法取得市場數據",
164
+ "",
165
  empty_df,
166
  empty_df,
167
  empty_fig,
 
219
  | 日銷售量 | {sale_velocity:.1f} |
220
 
221
  *最後更新: {last_update}*
222
+ """
223
+
224
+ # 建立物品資訊卡
225
+ # 取得物品描述
226
+ item_desc = item_info.get("Description", "")
227
+ if item_desc:
228
+ # 截斷過長的描述
229
+ if len(item_desc) > 150:
230
+ item_desc = item_desc[:150] + "..."
231
+
232
+ # 判斷是否可交易
233
+ is_untradable = item_info.get("IsUntradable", False)
234
+ tradable_text = "❌ 不可交易" if is_untradable else "✅ 可交易"
235
+
236
+ # 堆疊上限
237
+ stack_size = item_info.get("StackSize", 1)
238
+
239
+ # NPC 售價(賣給商店的價格)
240
+ vendor_price = item_info.get("PriceLow", 0)
241
+
242
+ # ClassJob ID 對應表
243
+ craft_job_names = {
244
+ 8: "刻木匠", 9: "鍛鐵匠", 10: "鑄甲匠", 11: "雕金匠",
245
+ 12: "製革匠", 13: "裁縫師", 14: "煉金術士", 15: "烹調師",
246
+ }
247
+
248
+ # 職業縮寫對應表
249
+ job_abbr_names = {
250
+ "PLD": "騎士", "WAR": "戰士", "DRK": "暗黑騎士", "GNB": "絕槍戰士",
251
+ "WHM": "白魔法師", "SCH": "學者", "AST": "占星術士", "SGE": "賢者",
252
+ "MNK": "武僧", "DRG": "龍騎士", "NIN": "忍者", "SAM": "武士", "RPR": "鐮刀師", "VPR": "蝰蛇劍士",
253
+ "BRD": "吟遊詩人", "MCH": "機工士", "DNC": "舞者",
254
+ "BLM": "黑魔法師", "SMN": "召喚師", "RDM": "赤魔法師", "PCT": "繪靈法師",
255
+ "PGL": "格鬥家", "GLA": "劍術師", "MRD": "斧術師", "LNC": "槍術師",
256
+ "ARC": "弓箭手", "ROG": "雙劍師", "THM": "咒術師", "ACN": "秘術師", "CNJ": "幻術師",
257
+ "CRP": "刻木匠", "BSM": "鍛鐵匠", "ARM": "鑄甲匠", "GSM": "雕金匠",
258
+ "LTW": "製革匠", "WVR": "裁縫師", "ALC": "煉金術士", "CUL": "烹調師",
259
+ "MIN": "採礦工", "BTN": "園藝工", "FSH": "捕魚人",
260
+ "BLU": "青魔法師",
261
+ }
262
+
263
+ # === A. 獲取方式 ===
264
+ obtain_methods = []
265
+ gcl = item_info.get("GameContentLinks", {})
266
+ if not isinstance(gcl, dict):
267
+ gcl = {}
268
+
269
+ # 可製作
270
+ recipes = item_info.get("Recipes", [])
271
+ if recipes:
272
+ craft_jobs = []
273
+ for recipe in recipes[:2]:
274
+ job_id = recipe.get("ClassJobID", 0)
275
+ job_name = craft_job_names.get(job_id, "")
276
+ level = recipe.get("Level", 0)
277
+ if job_name:
278
+ craft_jobs.append(f"{job_name} Lv.{level}")
279
+ if craft_jobs:
280
+ obtain_methods.append(f"🔨 製作: {', '.join(craft_jobs)}")
281
+
282
+ # 可採集
283
+ if gcl.get("GatheringItem"):
284
+ obtain_methods.append("⛏️ 採集")
285
+
286
+ # NPC 商店
287
+ if gcl.get("GilShopItem"):
288
+ npc_price = item_info.get("PriceMid", 0)
289
+ if npc_price > 0:
290
+ obtain_methods.append(f"🏪 NPC 商店: {npc_price:,} Gil")
291
+ else:
292
+ obtain_methods.append("🏪 NPC 商店")
293
+
294
+ # 雇員探險
295
+ if gcl.get("RetainerTaskNormal"):
296
+ obtain_methods.append("📦 雇員探險")
297
+
298
+ obtain_text = "\n".join(obtain_methods) if obtain_methods else "(無資料)"
299
+
300
+ # === B. 用途資訊 ===
301
+ usage_methods = []
302
+
303
+ # 作為製作材料
304
+ recipe_links = gcl.get("Recipe", {})
305
+ ingredient_keys = [k for k in recipe_links.keys() if k.startswith("ItemIngredient")]
306
+ if ingredient_keys:
307
+ total_recipes = sum(len(recipe_links[k]) for k in ingredient_keys)
308
+ usage_methods.append(f"🔧 製作材料 ({total_recipes} 個配方)")
309
+
310
+ # 軍隊製作
311
+ if gcl.get("CompanyCraftSupplyItem"):
312
+ usage_methods.append("🏠 部隊工房材料")
313
+
314
+ # 理符任務
315
+ if gcl.get("CraftLeve") or gcl.get("LeveRewardItemGroup"):
316
+ usage_methods.append("📋 理符任務")
317
+
318
+ # 軍票上交
319
+ if gcl.get("GCSupplyDuty"):
320
+ usage_methods.append("🎖️ 軍票上交")
321
+
322
+ usage_text = "\n".join(usage_methods) if usage_methods else "(無資料)"
323
+
324
+ # === C. 裝備屬性 ===
325
+ equip_text = ""
326
+ equip_level = item_info.get("LevelEquip", 0)
327
+ damage_phys = item_info.get("DamagePhys", 0)
328
+ damage_mag = item_info.get("DamageMag", 0)
329
+ defense_phys = item_info.get("DefensePhys", 0)
330
+ defense_mag = item_info.get("DefenseMag", 0)
331
+
332
+ # 檢查是否為裝備(必須有傷害/防禦或裝備槽位)
333
+ equip_slot = item_info.get("EquipSlotCategory") or {}
334
+ is_equipment = (
335
+ damage_phys > 0 or damage_mag > 0 or
336
+ defense_phys > 0 or defense_mag > 0 or
337
+ equip_slot.get("MainHand") or equip_slot.get("OffHand") or
338
+ equip_slot.get("Head") or equip_slot.get("Body") or
339
+ equip_slot.get("Gloves") or equip_slot.get("Legs") or
340
+ equip_slot.get("Feet") or equip_slot.get("Ears") or
341
+ equip_slot.get("Neck") or equip_slot.get("Wrists") or
342
+ equip_slot.get("FingerL") or equip_slot.get("FingerR")
343
+ )
344
+
345
+ if is_equipment:
346
+ equip_lines = [f"**裝備等級:** Lv.{equip_level}"]
347
+
348
+ # 職業限制
349
+ cjc = item_info.get("ClassJobCategory") or {}
350
+ jobs = [job_abbr_names.get(k, k) for k, v in cjc.items()
351
+ if v == 1 and not k.endswith("Target") and k != "ID" and k in job_abbr_names]
352
+ if jobs:
353
+ if len(jobs) > 5:
354
+ equip_lines.append(f"**職業:** {', '.join(jobs[:5])} 等 {len(jobs)} 職業")
355
+ else:
356
+ equip_lines.append(f"**職業:** {', '.join(jobs)}")
357
+
358
+ # 武器傷害
359
+ if damage_phys > 0 or damage_mag > 0:
360
+ if damage_phys > damage_mag:
361
+ equip_lines.append(f"⚔️ 物理傷害: {damage_phys}")
362
+ else:
363
+ equip_lines.append(f"✨ 魔法傷害: {damage_mag}")
364
+
365
+ # 防具防禦
366
+ if defense_phys > 0 or defense_mag > 0:
367
+ equip_lines.append(f"🛡️ 防禦: {defense_phys} / 魔防: {defense_mag}")
368
+
369
+ # 屬性加成
370
+ stats = []
371
+ for i in range(6):
372
+ param = item_info.get(f"BaseParam{i}Target")
373
+ value = item_info.get(f"BaseParamValue{i}", 0)
374
+ if param and value:
375
+ # param 可能是字典或字串
376
+ if isinstance(param, dict):
377
+ name = param.get("Name", "")
378
+ else:
379
+ name = str(param) if param else ""
380
+ if name:
381
+ stats.append(f"{name} +{value}")
382
+ if stats:
383
+ equip_lines.append(f"📊 {', '.join(stats[:4])}")
384
+
385
+ equip_text = "\n".join(equip_lines)
386
+
387
+ # 組合物品資訊卡
388
+ item_card = f"""### 🏷️ 物品資訊
389
+ **物品 ID:** `{item_id}` | 📦 堆疊: {stack_size}
390
+
391
+ {tradable_text}
392
+ {f"💰 NPC 售價: {vendor_price:,} Gil" if vendor_price > 0 else ""}
393
+ """
394
+
395
+ # 裝備屬性(如果是裝備)
396
+ if equip_text:
397
+ item_card += f"""
398
+ ---
399
+ ### ⚔️ 裝備屬性
400
+ {equip_text}
401
+ """
402
+
403
+ # 獲取方式
404
+ item_card += f"""
405
+ ---
406
+ ### 📍 獲取方式
407
+ {obtain_text}
408
+ """
409
+
410
+ # 用途資訊
411
+ if usage_methods:
412
+ item_card += f"""
413
+ ---
414
+ ### 📦 用途
415
+ {usage_text}
416
+ """
417
+
418
+ # 外部連結
419
+ item_card += f"""
420
+ ---
421
+ ### 🔗 外部連結
422
+ - [Universalis](https://universalis.app/market/{item_id})
423
+ - [Teamcraft](https://ffxivteamcraft.com/db/zh/item/{item_id})
424
+ - [Garland Tools](https://garlandtools.org/db/#item/{item_id})
425
+ """
426
+
427
+ # 物品說明
428
+ if item_desc:
429
+ item_card += f"""
430
+ ---
431
+ ### 📜 說明
432
+ *{item_desc}*
433
  """
434
 
435
  return (
436
  info_text,
437
+ item_card,
438
  listings_df,
439
  history_df,
440
  price_chart,
src/styles.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """自訂 CSS 樣式."""
2
+
3
+ CUSTOM_CSS = """
4
+ /* 頁首樣式 */
5
+ .header-box {
6
+ background: linear-gradient(135deg, #b8860b 0%, #daa520 50%, #b8860b 100%);
7
+ border-radius: 12px;
8
+ padding: 20px 24px;
9
+ margin-bottom: 16px;
10
+ }
11
+
12
+ .header-box h1 {
13
+ color: #1a1a2e !important;
14
+ margin: 0 0 8px 0 !important;
15
+ }
16
+
17
+ .header-box p {
18
+ color: #2c2c2c !important;
19
+ margin: 4px 0 !important;
20
+ }
21
+
22
+ .header-box a {
23
+ color: #1a1a2e !important;
24
+ font-weight: 700;
25
+ text-decoration: underline;
26
+ }
27
+
28
+ /* 伺服器標籤 */
29
+ .server-tags {
30
+ display: flex;
31
+ flex-wrap: wrap;
32
+ gap: 6px;
33
+ margin-top: 10px;
34
+ }
35
+
36
+ .server-tag {
37
+ background: rgba(0,0,0,0.2);
38
+ color: #1a1a2e;
39
+ padding: 4px 10px;
40
+ border-radius: 12px;
41
+ font-size: 0.8em;
42
+ font-weight: 600;
43
+ }
44
+
45
+ /* 狀態標籤 */
46
+ .status-badge {
47
+ display: inline-block;
48
+ background: #27ae60;
49
+ color: #ffffff;
50
+ padding: 6px 12px;
51
+ border-radius: 20px;
52
+ font-size: 0.85em;
53
+ margin-top: 12px;
54
+ font-weight: 500;
55
+ }
56
+
57
+ /* 分類 Radio 按鈕群組樣式 */
58
+ .category-radio {
59
+ margin: 8px 0;
60
+ }
61
+
62
+ .category-radio .wrap {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ gap: 6px;
66
+ }
67
+
68
+ .category-radio label {
69
+ padding: 6px 14px !important;
70
+ border: 1px solid var(--border-color-primary) !important;
71
+ border-radius: 8px !important;
72
+ background: var(--background-fill-secondary) !important;
73
+ cursor: pointer;
74
+ transition: all 0.2s ease;
75
+ font-size: 0.9em;
76
+ }
77
+
78
+ .category-radio label:hover {
79
+ background: var(--background-fill-primary) !important;
80
+ border-color: var(--color-accent) !important;
81
+ }
82
+
83
+ .category-radio input[type="radio"]:checked + span {
84
+ background: var(--color-accent) !important;
85
+ color: #1a1a2e !important;
86
+ border-color: var(--color-accent) !important;
87
+ font-weight: 600;
88
+ }
89
+
90
+ /* Gradio 5 相容 */
91
+ .category-radio .selected {
92
+ background: var(--color-accent) !important;
93
+ color: #1a1a2e !important;
94
+ border-color: var(--color-accent) !important;
95
+ font-weight: 600;
96
+ }
97
+ """