- Created useSearchSession composable for session management - Added 7 API endpoints in api/index.ts - Updated SearchView.vue to use sessions instead of history - Session sidebar with create, rename, delete functionality - Auto-save messages to current session after each search - Auto-create default session on first load - Updated CSS for session active state and new session button
26 KiB
26 KiB
Momentry Studio 整合計劃
日期:2026-10-27 目標:將 Search Session API 整合到 Momentry Studio 前端 預計工作量:2-3 小時(Studio 團隊) 前置條件:✅ Search Session API 已實施並部署
📋 整合步驟
Phase 1:前端基礎設施(30 分鐘)
1.1 創建 API Service 層
文件:src/api/searchSession.ts(新建)
// src/api/searchSession.ts
import type { SearchSession, SearchMessage } from '@/types/search';
const API_BASE = import.meta.env.VITE_MARKBASE_URL || 'http://localhost:11438';
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
code?: string;
}
export class SearchSessionApi {
private token: string;
constructor(token: string) {
this.token = token;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json();
return {
success: false,
error: error.error || 'Request failed',
code: error.code,
};
}
const data = await response.json();
return { success: true, data };
}
// Session CRUD
async listSessions(limit = 30, offset = 0): Promise<ApiResponse<{ sessions: SearchSession[]; total: number }>> {
return this.request(`/api/v2/search-sessions?limit=${limit}&offset=${offset}`);
}
async createSession(title: string, mode: string): Promise<ApiResponse<{ session: SearchSession }>> {
return this.request('/api/v2/search-sessions', {
method: 'POST',
body: JSON.stringify({ title, mode }),
});
}
async updateSession(id: string, title: string): Promise<ApiResponse<void>> {
return this.request(`/api/v2/search-sessions/${id}`, {
method: 'PUT',
body: JSON.stringify({ title }),
});
}
async deleteSession(id: string): Promise<ApiResponse<void>> {
return this.request(`/api/v2/search-sessions/${id}`, {
method: 'DELETE',
});
}
// Messages
async listMessages(sessionId: string): Promise<ApiResponse<{ messages: SearchMessage[] }>> {
return this.request(`/api/v2/search-sessions/${sessionId}/messages`);
}
async createMessage(
sessionId: string,
role: 'user' | 'assistant',
content: string,
mode: string,
results?: string
): Promise<ApiResponse<{ message: SearchMessage }>> {
return this.request(`/api/v2/search-sessions/${sessionId}/messages`, {
method: 'POST',
body: JSON.stringify({ role, content, mode, results: results || '' }),
});
}
async updateMessage(
sessionId: string,
messageId: string,
updates: Partial<Pick<SearchMessage, 'role' | 'content' | 'results'>>
): Promise<ApiResponse<void>> {
return this.request(`/api/v2/search-sessions/${sessionId}/messages/${messageId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
}
}
1.2 定義 TypeScript 類型
文件:src/types/search.ts(新建)
// src/types/search.ts
export interface SearchSession {
id: string;
title: string;
mode: 'keyword' | 'visual' | 'semantic';
message_count: number;
created_at: string;
updated_at: string;
}
export interface SearchMessage {
id: string;
role: 'user' | 'assistant';
content: string;
results: string; // JSON string
mode: 'keyword' | 'visual' | 'semantic';
created_at: string;
}
export interface SearchSessionList {
sessions: SearchSession[];
total: number;
}
export interface SearchConversation {
session: SearchSession;
messages: SearchMessage[];
}
Phase 2:Composable 層(40 分鐘)
2.1 創建 useSearchSession Composable
文件:src/composables/useSearchSession.ts(新建)
// src/composables/useSearchSession.ts
import { ref, computed, onMounted } from 'vue';
import { SearchSessionApi } from '@/api/searchSession';
import type { SearchSession, SearchMessage } from '@/types/search';
export function useSearchSession() {
const token = localStorage.getItem('markbase_token') || '';
const api = new SearchSessionApi(token);
// State
const sessions = ref<SearchSession[]>([]);
const currentSession = ref<SearchSession | null>(null);
const messages = ref<SearchMessage[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
// Computed
const hasCurrentSession = computed(() => currentSession.value !== null);
const totalSessions = computed(() => sessions.value.length);
// Actions
async function loadSessions() {
isLoading.value = true;
error.value = null;
const response = await api.listSessions();
if (response.success && response.data) {
sessions.value = response.data.sessions;
} else {
error.value = response.error || 'Failed to load sessions';
}
isLoading.value = false;
}
async function createNewSession(title: string, mode: 'keyword' | 'visual' | 'semantic' = 'keyword') {
isLoading.value = true;
error.value = null;
const response = await api.createSession(title, mode);
if (response.success && response.data) {
sessions.value.unshift(response.data.session);
currentSession.value = response.data.session;
messages.value = [];
} else {
error.value = response.error || 'Failed to create session';
}
isLoading.value = false;
}
async function loadSession(sessionId: string) {
isLoading.value = true;
error.value = null;
// Load session details
const session = sessions.value.find(s => s.id === sessionId);
if (session) {
currentSession.value = session;
}
// Load messages
const response = await api.listMessages(sessionId);
if (response.success && response.data) {
messages.value = response.data.messages;
} else {
error.value = response.error || 'Failed to load messages';
}
isLoading.value = false;
}
async function sendMessage(content: string, mode: 'keyword' | 'visual' | 'semantic' = 'keyword') {
if (!currentSession.value) {
error.value = 'No active session';
return;
}
isLoading.value = true;
error.value = null;
// Add user message
const userMsgResponse = await api.createMessage(
currentSession.value.id,
'user',
content,
mode
);
if (!userMsgResponse.success) {
error.value = userMsgResponse.error || 'Failed to send message';
isLoading.value = false;
return;
}
if (userMsgResponse.data) {
messages.value.push(userMsgResponse.data.message);
}
// TODO: Call search backend and get results
// For now, simulate assistant response
const assistantMsgResponse = await api.createMessage(
currentSession.value.id,
'assistant',
'Search results will appear here...',
mode,
JSON.stringify([{ file: 'example.txt', score: 0.95 }])
);
if (assistantMsgResponse.success && assistantMsgResponse.data) {
messages.value.push(assistantMsgResponse.data.message);
}
isLoading.value = false;
}
async function updateSessionTitle(sessionId: string, title: string) {
const response = await api.updateSession(sessionId, title);
if (response.success) {
const session = sessions.value.find(s => s.id === sessionId);
if (session) {
session.title = title;
}
} else {
error.value = response.error || 'Failed to update session';
}
}
async function deleteSession(sessionId: string) {
const response = await api.deleteSession(sessionId);
if (response.success) {
sessions.value = sessions.value.filter(s => s.id !== sessionId);
if (currentSession.value?.id === sessionId) {
currentSession.value = null;
messages.value = [];
}
} else {
error.value = response.error || 'Failed to delete session';
}
}
// Auto-load sessions on mount
onMounted(() => {
loadSessions();
});
return {
// State
sessions,
currentSession,
messages,
isLoading,
error,
// Computed
hasCurrentSession,
totalSessions,
// Actions
loadSessions,
createNewSession,
loadSession,
sendMessage,
updateSessionTitle,
deleteSession,
};
}
Phase 3:UI 組件(60 分鐘)
3.1 創建 SessionList 組件
文件:src/components/search/SessionList.vue(新建)
<!-- src/components/search/SessionList.vue -->
<template>
<div class="session-list">
<div class="session-header">
<h3>搜索歷史</h3>
<button
class="new-session-btn"
@click="$emit('create-new')"
:disabled="isLoading"
>
+ 新搜尋
</button>
</div>
<div class="sessions" v-if="sessions.length > 0">
<SessionItem
v-for="session in sessions"
:key="session.id"
:session="session"
:is-active="currentSession?.id === session.id"
@click="$emit('select', session.id)"
@update="handleUpdate(session.id, $event)"
@delete="handleDelete(session.id)"
/>
</div>
<div class="empty-state" v-else>
<p>尚無搜索記錄</p>
<button @click="$emit('create-new')">開始第一次搜尋</button>
</div>
<div class="loading" v-if="isLoading">
<span>載入中...</span>
</div>
<div class="error" v-if="error">
<span>{{ error }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import SessionItem from './SessionItem.vue';
import type { SearchSession } from '@/types/search';
defineProps<{
sessions: SearchSession[];
currentSession: SearchSession | null;
isLoading: boolean;
error: string | null;
}>();
const emit = defineEmits<{
(e: 'create-new'): void;
(e: 'select', sessionId: string): void;
(e: 'update', sessionId: string, title: string): void;
(e: 'delete', sessionId: string): void;
}>();
function handleUpdate(sessionId: string, title: string) {
emit('update', sessionId, title);
}
function handleDelete(sessionId: string) {
if (confirm('確定要刪除此搜尋記錄?')) {
emit('delete', sessionId);
}
}
</script>
<style scoped>
.session-list {
width: 280px;
height: 100%;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
background: #fafafa;
}
.session-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.session-header h3 {
margin: 0;
font-size: 16px;
}
.new-session-btn {
padding: 6px 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.new-session-btn:hover {
background: #40a9ff;
}
.sessions {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #999;
}
.empty-state button {
margin-top: 12px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.loading, .error {
padding: 12px;
text-align: center;
}
.error {
color: #f5222d;
}
</style>
3.2 創建 SessionItem 組件
文件:src/components/search/SessionItem.vue(新建)
<!-- src/components/search/SessionItem.vue -->
<template>
<div
class="session-item"
:class="{ active: isActive }"
@click="$emit('click')"
>
<div class="session-title" v-if="!isEditing">
{{ session.title }}
</div>
<input
v-else
v-model="editTitle"
class="session-title-input"
@blur="handleSave"
@keyup.enter="handleSave"
@keyup.esc="isEditing = false"
ref="titleInput"
/>
<div class="session-meta">
<span class="mode-badge">{{ modeText }}</span>
<span class="message-count">{{ session.message_count }} 則訊息</span>
</div>
<div class="session-actions" v-if="isActive">
<button class="action-btn" @click.stop="startEdit" title="重新命名">
✏️
</button>
<button class="action-btn" @click.stop="$emit('delete')" title="刪除">
🗑️
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import type { SearchSession } from '@/types/search';
const props = defineProps<{
session: SearchSession;
isActive: boolean;
}>();
const emit = defineEmits<{
(e: 'click'): void;
(e: 'update', title: string): void;
(e: 'delete'): void;
}>();
const isEditing = ref(false);
const editTitle = ref('');
const titleInput = ref<HTMLInputElement | null>(null);
const modeText = computed(() => {
const modeMap = {
keyword: '關鍵字',
visual: '視覺搜尋',
semantic: '語意搜尋',
};
return modeMap[props.session.mode] || props.session.mode;
});
function startEdit() {
editTitle.value = props.session.title;
isEditing.value = true;
nextTick(() => {
titleInput.value?.focus();
titleInput.value?.select();
});
}
function handleSave() {
if (editTitle.value.trim() && editTitle.value !== props.session.title) {
emit('update', editTitle.value.trim());
}
isEditing.value = false;
}
</script>
<style scoped>
.session-item {
padding: 12px;
margin-bottom: 8px;
background: white;
border-radius: 6px;
cursor: pointer;
border: 1px solid #e0e0e0;
transition: all 0.2s;
position: relative;
}
.session-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.session-item.active {
border-color: #1890ff;
background: #e6f7ff;
}
.session-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.session-title-input {
width: 100%;
font-size: 14px;
font-weight: 500;
border: 1px solid #1890ff;
border-radius: 4px;
padding: 4px 8px;
margin-bottom: 8px;
}
.session-meta {
display: flex;
gap: 8px;
font-size: 12px;
color: #999;
}
.mode-badge {
padding: 2px 6px;
background: #f0f0f0;
border-radius: 4px;
font-size: 11px;
}
.session-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.action-btn {
padding: 4px 8px;
background: transparent;
border: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 1;
}
</style>
3.3 創建 ChatArea 組件
文件:src/components/search/ChatArea.vue(新建)
<!-- src/components/search/ChatArea.vue -->
<template>
<div class="chat-area">
<div class="chat-header" v-if="currentSession">
<h2>{{ currentSession.title }}</h2>
<span class="mode-badge">{{ modeText }}</span>
</div>
<div class="messages" ref="messagesContainer">
<MessageItem
v-for="message in messages"
:key="message.id"
:message="message"
/>
<div class="empty-chat" v-if="messages.length === 0">
<p>開始新的搜尋對話</p>
</div>
</div>
<div class="input-area">
<select v-model="searchMode" class="mode-selector">
<option value="keyword">關鍵字搜尋</option>
<option value="visual">視覺搜尋</option>
<option value="semantic">語意搜尋</option>
</select>
<input
v-model="inputText"
class="chat-input"
placeholder="輸入搜尋內容..."
@keyup.enter="handleSend"
:disabled="isLoading || !currentSession"
/>
<button
class="send-btn"
@click="handleSend"
:disabled="isLoading || !currentSession || !inputText.trim()"
>
發送
</button>
</div>
<div class="loading-overlay" v-if="isLoading">
<span>搜尋中...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import MessageItem from './MessageItem.vue';
import type { SearchSession, SearchMessage } from '@/types/search';
const props = defineProps<{
currentSession: SearchSession | null;
messages: SearchMessage[];
isLoading: boolean;
}>();
const emit = defineEmits<{
(e: 'send', content: string, mode: string): void;
}>();
const inputText = ref('');
const searchMode = ref<'keyword' | 'visual' | 'semantic'>('keyword');
const messagesContainer = ref<HTMLElement | null>(null);
const modeText = computed(() => {
const modeMap = {
keyword: '關鍵字',
visual: '視覺搜尋',
semantic: '語意搜尋',
};
return modeMap[props.currentSession?.mode || 'keyword'] || '關鍵字';
});
function handleSend() {
if (!inputText.value.trim()) return;
emit('send', inputText.value.trim(), searchMode.value);
inputText.value = '';
}
// Auto-scroll to bottom when new messages arrive
watch(
() => props.messages.length,
() => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
});
}
);
</script>
<style scoped>
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: white;
}
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h2 {
margin: 0;
font-size: 18px;
}
.mode-badge {
padding: 4px 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.empty-chat {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #999;
}
.input-area {
padding: 16px 24px;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 12px;
align-items: center;
}
.mode-selector {
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
background: white;
}
.chat-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.chat-input:focus {
outline: none;
border-color: #1890ff;
}
.send-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.send-btn:hover:not(:disabled) {
background: #40a9ff;
}
.send-btn:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
font-size: 16px;
color: #1890ff;
}
</style>
3.4 創建 MessageItem 組件
文件:src/components/search/MessageItem.vue(新建)
<!-- src/components/search/MessageItem.vue -->
<template>
<div class="message-item" :class="message.role">
<div class="message-avatar">
{{ message.role === 'user' ? '👤' : '🤖' }}
</div>
<div class="message-content">
<div class="message-header">
<span class="role-label">{{ message.role === 'user' ? '用戶' : '助手' }}</span>
<span class="timestamp">{{ formatTime(message.created_at) }}</span>
</div>
<div class="message-text">
{{ message.content }}
</div>
<div class="message-results" v-if="message.results">
<div class="results-header">搜索結果:</div>
<div class="results-content">
<ResultItem
v-for="(result, index) in parseResults(message.results)"
:key="index"
:result="result"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import ResultItem from './ResultItem.vue';
import type { SearchMessage } from '@/types/search';
const props = defineProps<{
message: SearchMessage;
}>();
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
});
}
function parseResults(resultsJson: string): Array<{ file: string; score: number }> {
try {
return JSON.parse(resultsJson);
} catch {
return [];
}
}
</script>
<style scoped>
.message-item {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.message-item.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
font-size: 20px;
flex-shrink: 0;
}
.message-content {
flex: 1;
max-width: 80%;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #999;
}
.message-text {
padding: 12px 16px;
background: #f5f5f5;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-item.user .message-text {
background: #1890ff;
color: white;
}
.message-results {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.results-header {
font-size: 12px;
color: #999;
margin-bottom: 8px;
}
.results-content {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
Phase 4:視圖整合(30 分鐘)
4.1 修改 SearchView.vue
文件:src/views/SearchView.vue(修改)
<!-- src/views/SearchView.vue -->
<template>
<div class="search-view">
<SessionList
:sessions="sessions"
:current-session="currentSession"
:is-loading="isLoading"
:error="error"
@create-new="handleCreateNew"
@select="handleSelectSession"
@update="handleUpdateSession"
@delete="handleDeleteSession"
/>
<ChatArea
:current-session="currentSession"
:messages="messages"
:is-loading="isLoading"
@send="handleSendMessage"
/>
</div>
</template>
<script setup lang="ts">
import { useSearchSession } from '@/composables/useSearchSession';
import SessionList from '@/components/search/SessionList.vue';
import ChatArea from '@/components/search/ChatArea.vue';
const {
sessions,
currentSession,
messages,
isLoading,
error,
createNewSession,
loadSession,
sendMessage,
updateSessionTitle,
deleteSession,
} = useSearchSession();
async function handleCreateNew() {
const title = `搜尋 ${new Date().toLocaleDateString('zh-TW')}`;
await createNewSession(title, 'keyword');
}
async function handleSelectSession(sessionId: string) {
await loadSession(sessionId);
}
async function handleSendMessage(content: string, mode: string) {
await sendMessage(content, mode as 'keyword' | 'visual' | 'semantic');
}
async function handleUpdateSession(sessionId: string, title: string) {
await updateSessionTitle(sessionId, title);
}
async function handleDeleteSession(sessionId: string) {
await deleteSession(sessionId);
}
</script>
<style scoped>
.search-view {
display: flex;
height: 100vh;
background: #fafafa;
}
</style>
Phase 5:移除舊代碼(20 分鐘)
5.1 移除 search_history 相關代碼
文件清單:
-
src-tauri/src/db.rs:
- 移除
search_history表相關函數 - 移除
SearchHistoryItem結構體
- 移除
-
src-tauri/src/proxy.rs:
- 移除
search-historyhandler
- 移除
-
src-tauri/src/main.rs:
- 移除
search-historycommand 註冊
- 移除
示例:
// 移除前
#[tauri::command]
fn get_search_history() -> Vec<SearchHistoryItem> {
// ...
}
// 移除後
// (刪除整個函數)
Phase 6:測試與驗證(30 分鐘)
6.1 功能測試清單
| 測試項目 | 預期結果 | 狀態 |
|---|---|---|
| 創建新 Session | Session 出現在列表中 | ⏳ 待測 |
| 切換 Session | 對話記錄正確載入 | ⏳ 待測 |
| 發送訊息 | 訊息出現在對話中 | ⏳ 待測 |
| 更新 Session 標題 | 標題即時更新 | ⏳ 待測 |
| 刪除 Session | Session 從列表移除 | ⏳ 待測 |
| 自動清理 | 24 小時後清理未命名 Session | ⏳ 待測 |
| 用戶隔離 | 不同用戶看不到對方數據 | ⏳ 待測 |
6.2 性能測試
| 測試項目 | 目標 | 狀態 |
|---|---|---|
| 載入 30 個 Sessions | < 500ms | ⏳ 待測 |
| 載入 100 條訊息 | < 1s | ⏳ 待測 |
| 創建新 Session | < 100ms | ⏳ 待測 |
| 發送訊息 | < 200ms | ⏳ 待測 |
📝 移交清單
給 Studio 團隊
必須完成:
- 創建所有新文件(API、composable、components)
- 修改 SearchView.vue
- 移除舊的 search_history 代碼
- 更新環境變數(VITE_MARKBASE_URL)
- 測試所有功能
可選優化:
- 添加離線緩存
- 添加搜索結果高亮
- 添加拖拽排序 Sessions
- 添加 Session 分享功能
🔗 相關資源
API 文檔:
/Users/accusys/markbase/docs/SEARCH_SESSION_API_REPORT.md/Users/accusys/momentry_studio/docs/MARKBASE_SEARCH_SESSION_API.md
API 端點:
http://localhost:11438/api/v2/search-sessions- Token: 從
/api/v2/admin/login獲取
測試帳號:
- Username:
demo - Password:
demo123
⚠️ 注意事項
-
Token 管理:
- Token 存儲在 localStorage
- Token 過期時需要重新登錄
-
錯誤處理:
- API 返回
{ success: false, error: "..." }格式 - 需要統一處理錯誤提示
- API 返回
-
性能考慮:
- Sessions 列表默認 30 條
- 需要實現滾動載入(無限滾動)
-
用戶隔離:
- API 自動根據 Token 過濾 user_id
- 前端不需要手動傳遞 user_id
🎯 成功標準
功能完整性:
- ✅ 所有 7 個 API 端點可用
- ✅ Session CRUD 功能正常
- ✅ 訊息發送/接收功能正常
性能達標:
- ✅ API 響應時間 < 100ms
- ✅ UI 流暢度 > 60fps
用戶體驗:
- ✅ 直觀的 UI 操作
- ✅ 即時反饋
- ✅ 錯誤提示友好
整合計劃完成,Studio 團隊可開始實施。
預計完成時間:2-3 小時 聯繫方式:如有問題請參考 API 文檔或聯繫 MarkBase 團隊