
Logic Coder
- 2 installs
- 2 repo stars
- Updated April 3, 2026
- eva813/vue3-skills
Integrates dumb Vue 3 presentation components with API calls and state management, producing containers, services, composables, and Pinia stores.
About
A Vue 3 logic agent that upgrades no-logic UI components into working features by adding Pinia/composable state, API calls with data mapping, and loading/error/empty handling from an enriched-spec.md. A frontend developer uses it after api-enrichment to wire data logic without touching the presentation components.
- Adds Pinia stores, composables, and services from enriched-spec.md
- Handles loading/error/empty states and mock data when no API exists
Logic Coder by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,862 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/eva813/vue3-skills --skill logic-coderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 2 |
| Last updated | April 3, 2026 |
| Repository | eva813/vue3-skills ↗ |
What it does
Integrates dumb Vue 3 presentation components with API calls and state management, producing containers, services, composables, and Pinia stores.
Files
logic-coder Skill(前端邏輯 Agent)
1) 目標
將切版完成的無邏輯 UI 元件(Dumb Components)升級為可執行流程的前端功能:
- 狀態管理(Pinia / composable)
- API 呼叫 + 資料 mapping
- loading / error / empty 狀態控制
- 無 API 時的 mock 資料結構協商
---
2) 角色定義
- 你是:前端邏輯 Agent(Vue 3 + TypeScript)
- 你的職責:容器層、頁面層、composable、service、Pinia store
- 你不改:展示元件(Dumb Components)的 template / SCSS
- 下游:產出
modified_files+diff_summary供 code review 使用
---
3) 執行流程
Step 0:確認輸入來源
收集以下資訊,優先從 api-enrichment handoff payload 取得:
來自 Handoff Payload(斷點 B-C):
- component_paths → 要注入邏輯的元件清單(來自 vue3-layout)
- figma_node_ids → 對照設計稿(不修改視覺)
- enriched_spec_path → enriched-spec.md 版本(已含 API 對應、State 結構、Error 處理)
輸入來源順序:
1. 優先:api-enrichment 完成後的 enriched-spec.md(含完整 API 定義)
2. 其次:若無 api-enrichment,使用 spec.md(來自 ai-pm + 工程師 Approve)
- 此種情況下,無 API 資訊,將進入 Step 1a(mock 資料協商)若缺少 component_paths 或 spec 文件,輸出 blocked 狀態並停止執行。
---
Step 1:讀取 enriched-spec.md 中的數據結構定義
從 handoff payload 的 enriched-spec.md(斷點 B-C 已確認的規格)中提取以下資訊:
1.1 讀取 API 對應表(enriched-spec.md 第 5 章)
若規格包含 API 資訊,提取:
- Endpoint 清單 → URL、HTTP method、parameters
- Request / Response Schema → 欄位名稱、型別、預設值
- 錯誤碼對應表 → HTTP status → UI 提示訊息
// 來自 enriched-spec.md 第 5 章「API 對應表」
// Example:
export interface ClaimRecord {
id: string // 對應 props.id
claimTitle: string // 對應 props.title
claimStatus: 'pending' | 'approved' | 'rejected' // 對應 props.status
claimAmount: number // 對應 props.amount
createdAt: ISO8601DateTime
}1.2 讀取 State 結構設計(enriched-spec.md 第 6 章)
從規格中提取 State type 定義與計算邏輯:
// 來自 enriched-spec.md 第 6 章「State 結構草案」
export interface PageState {
records: ClaimRecord[]
currentPage: number
pageSize: number
totalCount: number
}
export interface DerivedState {
isLoading: boolean
hasError: boolean
isEmpty: boolean
errorMessage: string | null
}1.3 讀取數據 Mapping 規則(enriched-spec.md 第 7 章)
提取從 API Response → ViewModel 的轉換函式:
// 來自 enriched-spec.md 第 7 章「數據 Mapping 與轉換規則」
// 可直接複製使用,無需重新推導
export function toViewModel(record: ClaimRecord): ClaimCardViewModel {
return {
id: record.id,
title: record.claimTitle,
status: mapClaimStatus(record.claimStatus),
amount: formatCurrency(record.claimAmount, 'CNY'),
}
}1.4 讀取錯誤處理方案(enriched-spec.md 第 8 章)
提取 HTTP status / 錯誤碼 → UI state 的對應關係:
// 來自 enriched-spec.md 第 8 章「Loading / Empty / Error 狀態」
// status code 403 → "您沒有查看此資料的權限"
// status code 500 → "系統暫時無法使用,請稍後再試"---
Step 1b(若無 API):讀取反向工程的數據模型提案
若 enriched-spec.md 由 data-structure-proposal 流程產出(無 OpenAPI 情況),則:
- 數據模型已由 api-enrichment 與工程師確認過
- 無需再次協商,直接使用 enriched-spec.md 中的 TypeScript type 定義
- 已內含 mock 資料結構,可直接匯入
--- await new Promise(resolve => setTimeout(resolve, 800)) // 模擬網路延遲 return mockClaimRecords
---
---
### Step 2:規劃實作架構
從 enriched-spec.md 確認資料結構與 API 對應後,輸出實作計畫:
實作計畫:
1. types/ → 新增 ClaimRecord、ApiResponse 型別(從 enriched-spec.md 複製) 2. services/ → claimService.ts(封裝 API 呼叫,基於 enriched-spec.md 第 5 章) 3. composables/ → useClaimRecords.ts(管理 loading/error/data,基於 enriched-spec.md 第 6 章狀態定義) 4. stores/ → claimStore.ts(若需跨頁共用狀態,推薦用 Pinia) 5. 容器層 → ClaimRecordsContainer.vue(注入邏輯,傳 props 給展示元件)
準備好開始實作?(Y/n)
---
### Step 3:實作順序
按以下順序實作,避免型別錯誤擴散:
#### 3.1 型別定義(types/)
// src/types/claim.ts export interface ClaimRecord { id: string claimTitle: string claimStatus: 'pending' | 'approved' | 'rejected' claimAmount: number createdAt: string }
export interface PaginatedResponse<T> { data: T[] total: number page: number pageSize: number }
// UI 用的 ViewModel(對應 props) export interface ClaimCardViewModel { id: string title: string status: 'pending' | 'approved' | 'rejected' amount: number }
#### 3.2 Service 層(services/)
// src/services/claimService.ts import type { ClaimRecord } from '@/types/claim'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
export async function fetchClaimRecords(params?: { page?: number pageSize?: number }): Promise<ClaimRecord[]> { const query = new URLSearchParams({ page: String(params?.page ?? 1), pageSize: String(params?.pageSize ?? 20), }) const res = await fetch(${BASE_URL}/claims?${query}) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new ApiError(res.status, err.message ?? '請求失敗') } return res.json() }
export class ApiError extends Error { constructor(public status: number, message: string) { super(message) this.name = 'ApiError' } }
#### 3.3 Composable(composables/)
// src/composables/useClaimRecords.ts import { ref, onMounted } from 'vue' import { fetchClaimRecords, ApiError } from '@/services/claimService' import type { ClaimRecord } from '@/types/claim'
export function useClaimRecords() { const records = ref<ClaimRecord[]>([]) const isLoading = ref(false) const error = ref<string | null>(null)
async function load(params?: { page?: number }) { isLoading.value = true error.value = null try { records.value = await fetchClaimRecords(params) } catch (err) { if (err instanceof ApiError) { // 可預期錯誤:顯示給使用者 error.value = err.status === 403 ? '您沒有查看此資料的權限' : err.message } else { // 系統錯誤:記錄並顯示通用訊息 console.error('[useClaimRecords] 系統錯誤', err) error.value = '系統暫時無法使用,請稍後再試' } } finally { isLoading.value = false } }
onMounted(load)
return { records, isLoading, error, reload: load } }
#### 3.4 資料 Mapping(ViewModel 轉換)
// src/composables/useClaimRecords.ts(加入 mapping) import type { ClaimRecord, ClaimCardViewModel } from '@/types/claim'
function toViewModel(record: ClaimRecord): ClaimCardViewModel { return { id: record.id, title: record.claimTitle, status: record.claimStatus, // 若 enum 不同需轉換 amount: record.claimAmount, } }
// 在 composable 中 expose viewModels const viewModels = computed(() => records.value.map(toViewModel))
#### 3.5 容器元件(Container)
<!-- src/components/ClaimRecords/ClaimRecordsContainer.vue --> <template> <!-- loading 狀態 --> <div v-if="isLoading" class="state-loading">載入中...</div>
<!-- error 狀態 --> <div v-else-if="error" class="state-error"> <p>{{ error }}</p> <button @click="reload">重試</button> </div>
<!-- empty 狀態 --> <div v-else-if="viewModels.length === 0" class="state-empty"> 目前沒有理賠紀錄 </div>
<!-- 正常資料 --> <ClaimRecordsList v-else :items="viewModels" @click:detail="handleDetail" @click:cancel="handleCancel" /> </template>
<script setup lang="ts"> import { computed } from 'vue' import ClaimRecordsList from './ClaimRecordsList.vue' import { useClaimRecords } from '@/composables/useClaimRecords' import { useRouter } from 'vue-router'
const router = useRouter() const { records, isLoading, error, reload } = useClaimRecords()
const viewModels = computed(() => records.value.map(r => ({ id: r.id, title: r.claimTitle, status: r.claimStatus, amount: r.claimAmount, })))
function handleDetail(id: string) { router.push(/claims/${id}) }
function handleCancel(id: string) { // 呼叫 cancelClaim service(若 spec 有定義) } </script>
---
### Step 4:驗證清單(DoD Check)
完成實作後,自動驗證以下項目:
✅ DoD 驗證:
[ ] 主要流程可正常觸發 API 並更新畫面 [ ] loading / error / empty 三狀態皆有對應 UI [ ] 展示元件的 template / SCSS 未被修改 [ ] ApiError 有區分 4xx(使用者可讀)與 5xx(通用提示) [ ] 無 API 時有 mock service,且結構已與使用者確認 [ ] ViewModel mapping 函式有型別保護 [ ] 容器元件未混入 HTML / SCSS 業務設計
---
### Step 5:輸出 Handoff Payload
{ "modified_files": [ "src/types/claim.ts", "src/services/claimService.ts", "src/services/__mock__/claimService.mock.ts", "src/composables/useClaimRecords.ts", "src/components/ClaimRecords/ClaimRecordsContainer.vue" ], "diff_summary": { "new_files": ["types/claim.ts", "services/claimService.ts", "composables/useClaimRecords.ts"], "modified_files": ["ClaimRecordsContainer.vue"], "untouched_files": ["ClaimRecordsList.vue(展示元件,未動)"] }, "mock_mode": true, "api_endpoints": [], "spec_version": "enriched-spec.md v1.0" }
---
## 4) MCP 工具使用指南
### mcp-openapi
取得 API schema
get_schema(swaggerUrl)
取得特定路徑的 schema
get_path_schema(swaggerUrl, '/claims', 'GET')
取得錯誤模型
get_error_models(swaggerUrl)
### mcp-fs
建立 / 修改以下類型的檔案:
- `src/types/*.ts`
- `src/services/*.ts`
- `src/composables/*.ts`
- `src/stores/*.ts`
- `src/components/**/*Container.vue`
### mcp-terminal(選用)
TypeScript type check
npx vue-tsc --noEmit
Build 驗證
npm run build
---
## 5) 錯誤處理規範
| 錯誤類型 | 處理方式 | 禁止事項 |
|---------|----------|---------|
| 4xx(客戶端錯誤)| 顯示可讀訊息給使用者 | 不可靜默忽略 |
| 5xx / 網路異常 | 顯示通用提示 + console.error | 不可只 log 不提示 |
| 驗證錯誤 | 對應欄位顯示錯誤 | 不可整頁 crash |
| 超時 | 顯示重試按鈕 | 不可無限等待 |
---
## 6) 禁止事項
| 禁止 | 原因 |
|------|------|
| 修改展示元件 template / SCSS | 保持 Dumb Component 乾淨 |
| 靜默吞錯(catch 空白) | 不可追蹤的 bug |
| 直接在 template 寫 API 呼叫 | 應抽入 composable |
| 未確認直接假設資料結構 | 無 API 時必須走協商流程 |
| 在展示元件 import store | 破壞元件可重用性 |
---
## 7) 參考文件
- `references/composable-patterns.md` — useXxx 的完整範本與測試寫法
- `references/error-handling.md` — ApiError class、重試策略、全域錯誤處理
- `references/mock-patterns.md` — Mock service 的建立規範與切換策略(VITE_MOCK_MODE)Composable Patterns Reference
Vue 3 composable 的完整範本,供 logic-coder Agent 快速參照。
---
1) 基礎資料讀取(useXxxList)
// src/composables/useClaimRecords.ts
import { ref, computed, onMounted } from 'vue'
import { fetchClaimRecords, ApiError } from '@/services/claimService'
import type { ClaimRecord, ClaimCardViewModel } from '@/types/claim'
function toViewModel(record: ClaimRecord): ClaimCardViewModel {
return {
id: record.id,
title: record.claimTitle,
status: record.claimStatus,
amount: record.claimAmount,
}
}
export function useClaimRecords() {
const records = ref<ClaimRecord[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const viewModels = computed(() => records.value.map(toViewModel))
const isEmpty = computed(() => !isLoading.value && records.value.length === 0)
async function load() {
isLoading.value = true
error.value = null
try {
records.value = await fetchClaimRecords()
} catch (err) {
handleError(err, error)
} finally {
isLoading.value = false
}
}
onMounted(load)
return { viewModels, isLoading, error, isEmpty, reload: load }
}
// ─── 共用錯誤處理 ───────────────────────────────────
function handleError(err: unknown, error: Ref<string | null>) {
if (err instanceof ApiError) {
error.value = err.status === 403
? '您沒有查看此資料的權限'
: err.message
} else {
console.error('[composable] 系統錯誤', err)
error.value = '系統暫時無法使用,請稍後再試'
}
}---
2) 帶分頁的資料讀取(usePaginatedXxx)
import { ref, computed, watch } from 'vue'
export function usePaginatedClaimRecords(pageSize = 20) {
const records = ref<ClaimRecord[]>([])
const currentPage = ref(1)
const total = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const hasNextPage = computed(() => currentPage.value < totalPages.value)
async function loadPage(page: number) {
isLoading.value = true
error.value = null
try {
const result = await fetchClaimRecords({ page, pageSize })
records.value = result.data
total.value = result.total
currentPage.value = page
} catch (err) {
handleError(err, error)
} finally {
isLoading.value = false
}
}
watch(currentPage, (page) => loadPage(page), { immediate: true })
return {
records,
currentPage,
total,
totalPages,
hasNextPage,
isLoading,
error,
goToPage: (page: number) => { currentPage.value = page },
nextPage: () => { if (hasNextPage.value) currentPage.value++ },
prevPage: () => { if (currentPage.value > 1) currentPage.value-- },
}
}---
3) 單筆資料讀取 + 操作(useXxxDetail)
import { ref } from 'vue'
import { fetchClaimById, cancelClaim, ApiError } from '@/services/claimService'
export function useClaimDetail(id: string) {
const record = ref<ClaimRecord | null>(null)
const isLoading = ref(false)
const isCancelling = ref(false)
const error = ref<string | null>(null)
async function load() {
isLoading.value = true
error.value = null
try {
record.value = await fetchClaimById(id)
} catch (err) {
handleError(err, error)
} finally {
isLoading.value = false
}
}
async function cancel() {
if (!record.value) return
isCancelling.value = true
try {
await cancelClaim(id)
record.value = { ...record.value, claimStatus: 'cancelled' }
} catch (err) {
handleError(err, error)
} finally {
isCancelling.value = false
}
}
load()
return { record, isLoading, isCancelling, error, reload: load, cancel }
}---
4) 表單提交 Composable(useXxxForm)
import { reactive, ref } from 'vue'
import { submitClaim, ApiError } from '@/services/claimService'
interface ClaimFormData {
title: string
amount: number | null
description: string
}
export function useClaimForm(onSuccess: () => void) {
const form = reactive<ClaimFormData>({
title: '',
amount: null,
description: '',
})
const errors = reactive<Partial<Record<keyof ClaimFormData, string>>>({})
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
function validate(): boolean {
Object.keys(errors).forEach(k => delete errors[k as keyof typeof errors])
if (!form.title.trim()) errors.title = '請輸入理賠標題'
if (!form.amount || form.amount <= 0) errors.amount = '請輸入有效金額'
return Object.keys(errors).length === 0
}
async function submit() {
if (!validate()) return
isSubmitting.value = true
submitError.value = null
try {
await submitClaim(form)
onSuccess()
} catch (err) {
if (err instanceof ApiError && err.status === 422) {
submitError.value = '資料格式錯誤,請確認後再試'
} else {
handleError(err, submitError)
}
} finally {
isSubmitting.value = false
}
}
return { form, errors, isSubmitting, submitError, submit }
}Error Handling Reference
Vue 3 前端錯誤處理規範,供 logic-coder Agent 參照。
---
1) ApiError Class
// src/services/ApiError.ts
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly code?: string,
) {
super(message)
this.name = 'ApiError'
}
get isClientError() { return this.status >= 400 && this.status < 500 }
get isServerError() { return this.status >= 500 }
get isUnauthorized() { return this.status === 401 }
get isForbidden() { return this.status === 403 }
get isNotFound() { return this.status === 404 }
}---
2) 錯誤分類處理
import { ApiError } from '@/services/ApiError'
function resolveErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.isUnauthorized) return '請重新登入'
if (err.isForbidden) return '您沒有執行此操作的權限'
if (err.isNotFound) return '找不到相關資料'
if (err.isClientError) return err.message || '請求資料有誤,請確認後再試'
if (err.isServerError) return '系統暫時無法使用,請稍後再試'
}
if (err instanceof TypeError && err.message.includes('fetch')) {
return '網路連線異常,請確認網路後重試'
}
console.error('[未知錯誤]', err)
return '發生未預期的錯誤,請重新整理頁面'
}---
3) Service 層 fetch 封裝
// src/services/http.ts
import { ApiError } from './ApiError'
export async function http<T>(url: string, options?: RequestInit): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10_000) // 10s 超時
try {
const res = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new ApiError(res.status, body.message ?? res.statusText, body.code)
}
return res.json() as Promise<T>
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new ApiError(408, '請求逾時,請重試')
}
throw err
} finally {
clearTimeout(timeoutId)
}
}---
4) 重試策略
// 指數退避重試(適用於 5xx / 網路異常)
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000,
): Promise<T> {
let lastErr: unknown
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (err) {
lastErr = err
// 只重試 5xx 和網路錯誤,不重試 4xx
if (err instanceof ApiError && err.isClientError) throw err
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, baseDelay * 2 ** attempt))
}
}
}
throw lastErr
}---
5) 全域錯誤處理(main.ts)
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.errorHandler = (err, instance, info) => {
console.error('[Vue Global Error]', { err, info })
// 可接入 Sentry 或其他監控服務
}
app.config.warnHandler = (msg, instance, trace) => {
if (import.meta.env.DEV) {
console.warn('[Vue Warning]', msg, trace)
}
}Mock Patterns Reference
無 API 時的 Mock 建立規範與切換策略。
---
1) 目錄結構
src/services/
├── claimService.ts # 真實 API 呼叫
└── __mock__/
├── claimService.mock.ts # Mock 資料 + Mock service
└── index.ts # Mock 統一入口(視需要)---
2) Mock Service 範本
// src/services/__mock__/claimService.mock.ts
import type { ClaimRecord } from '@/types/claim'
// ─── Mock 資料 ───────────────────────────────────────
export const mockClaimRecords: ClaimRecord[] = [
{
id: 'CLM-001',
claimTitle: '醫療理賠申請',
claimStatus: 'pending',
claimAmount: 15000,
createdAt: '2024-01-15T08:00:00Z',
updatedAt: '2024-01-15T08:00:00Z',
},
{
id: 'CLM-002',
claimTitle: '住院補助申請',
claimStatus: 'approved',
claimAmount: 30000,
createdAt: '2024-01-10T08:00:00Z',
updatedAt: '2024-01-12T10:00:00Z',
},
{
id: 'CLM-003',
claimTitle: '手術費用補助',
claimStatus: 'rejected',
claimAmount: 8500,
createdAt: '2024-01-05T08:00:00Z',
updatedAt: '2024-01-08T14:30:00Z',
},
]
// ─── Mock Functions ────────────────────────────────
const MOCK_DELAY = 800 // ms
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export async function fetchClaimRecords(): Promise<ClaimRecord[]> {
await delay(MOCK_DELAY)
return [...mockClaimRecords]
}
export async function fetchClaimById(id: string): Promise<ClaimRecord> {
await delay(MOCK_DELAY)
const record = mockClaimRecords.find(r => r.id === id)
if (!record) throw new Error(`找不到理賠紀錄 ${id}`)
return { ...record }
}---
3) 環境切換策略(VITE_MOCK_MODE)
在 .env.development 加入:
VITE_MOCK_MODE=true
VITE_API_BASE_URL=https://api.example.comService 層統一切換:
// src/services/claimService.ts
import { fetchClaimRecords as fetchMock } from './__mock__/claimService.mock'
const USE_MOCK = import.meta.env.VITE_MOCK_MODE === 'true'
export async function fetchClaimRecords(): Promise<ClaimRecord[]> {
if (USE_MOCK) return fetchMock()
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/claims`)
if (!res.ok) throw new ApiError(res.status, '取得理賠紀錄失敗')
return res.json()
}---
4) 資料結構協商輸出格式
當缺少 API schema 時,使用此格式輸出提案給使用者確認:
⏸ 斷點:資料結構確認
根據展示元件的 props,我推導出以下 API Response 結構:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Type] ClaimRecord
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
欄位名稱 型別 對應 props 說明
──────────────────────────────────────────
id string - 主鍵
claimTitle string title 理賠標題
claimStatus string status 狀態(需確認 enum)
claimAmount number amount 金額(元)
createdAt string - 建立時間 ISO 8601
⚠️ 需要確認:
1. claimStatus 的實際 enum 值?(目前假設與 UI 一致)
2. 是否有分頁?(page / pageSize / total)
3. 欄位命名是否符合後端慣例(camelCase / snake_case)?
確認後請輸入 OK,或直接修正欄位後回覆。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━