From 01d578c67e29722fb4a4a3a2980076739f6f451f Mon Sep 17 00:00:00 2001 From: egg Date: Wed, 17 Dec 2025 13:57:02 +0800 Subject: [PATCH] feat: Add SQLite database support and fixed portable extraction path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SQLite as alternative database for offline/firewall environments - Add --database-type parameter to build-client.bat (mysql/sqlite) - Refactor database.py to support both MySQL and SQLite - Add DB_TYPE and SQLITE_PATH configuration options - Set fixed unpackDirName for portable exe (Meeting-Assistant) - Update DEPLOYMENT.md with SQLite mode documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- DEPLOYMENT.md | 109 ++++++++++-- backend/app/config.py | 17 ++ backend/app/database.py | 353 +++++++++++++++++++++++++++++++-------- backend/run_server.py | 6 + client/config.json | 2 + client/package.json | 3 +- scripts/build-client.bat | 47 ++++++ 7 files changed, 456 insertions(+), 81 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 1e90076..40e93de 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -4,7 +4,7 @@ - Python 3.10+ - Node.js 18+ -- MySQL 8.0+ +- MySQL 8.0+ 或 SQLite(本地模式) - Access to Dify LLM service ## Quick Start @@ -233,18 +233,32 @@ npm start - 企業內部部署,簡化用戶操作 - 無法獨立架設後端的環境 - 快速測試和演示 +- **離線環境**:使用 SQLite 本地資料庫,無需網路連接資料庫 ### 打包方式 ```batch -# Windows 全包打包 +# Windows 全包打包(MySQL 雲端資料庫,預設) .\scripts\build-client.bat build --embedded-backend --clean + +# Windows 全包打包(SQLite 本地資料庫,適合離線/防火牆環境) +.\scripts\build-client.bat build --embedded-backend --database-type sqlite --clean ``` +**打包參數說明:** + +| 參數 | 說明 | +|------|------| +| `--embedded-backend` | 啟用內嵌後端模式 | +| `--database-type TYPE` | 資料庫類型:`mysql`(雲端)或 `sqlite`(本地) | +| `--clean` | 建置前清理所有暫存檔案 | + ### config.json 配置 全包模式需要在 `config.json` 中配置資料庫和 API 金鑰: +#### MySQL 模式(雲端資料庫) + ```json { "apiBaseUrl": "http://localhost:8000/api", @@ -260,6 +274,8 @@ npm start "host": "127.0.0.1", "port": 8000, "database": { + "type": "mysql", + "sqlitePath": "data/meeting.db", "host": "mysql.theaken.com", "port": 33306, "user": "your_username", @@ -281,6 +297,45 @@ npm start } ``` +#### SQLite 模式(本地資料庫) + +適合離線環境或網路防火牆阻擋資料庫連線的情況: + +```json +{ + "apiBaseUrl": "http://localhost:8000/api", + "uploadTimeout": 600000, + "appTitle": "Meeting Assistant", + "whisper": { + "model": "medium", + "device": "cpu", + "compute": "int8" + }, + "backend": { + "embedded": true, + "host": "127.0.0.1", + "port": 8000, + "database": { + "type": "sqlite", + "sqlitePath": "data/meeting.db" + }, + "externalApis": { + "authApiUrl": "https://pj-auth-api.vercel.app/api/auth/login", + "difyApiUrl": "https://dify.theaken.com/v1", + "difyApiKey": "app-xxxxxxxxxx", + "difySttApiKey": "app-xxxxxxxxxx" + }, + "auth": { + "adminEmail": "admin@example.com", + "jwtSecret": "your_secure_jwt_secret", + "jwtExpireHours": 24 + } + } +} +``` + +> **注意**:SQLite 模式下,資料庫檔案位於 `%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db` + ### 配置說明 | 區段 | 欄位 | 說明 | @@ -288,20 +343,40 @@ npm start | `backend.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 | | `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1) | | `backend.port` | 數字 | 後端監聽端口(預設 8000) | -| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊 | +| `backend.database.type` | `mysql`/`sqlite` | 資料庫類型(預設 mysql) | +| `backend.database.sqlitePath` | 路徑 | SQLite 資料庫檔案路徑(相對或絕對) | +| `backend.database.*` | 各欄位 | MySQL 資料庫連線資訊(僅 MySQL 模式需要) | | `backend.externalApis.*` | 各欄位 | 外部 API 設定(認證、Dify) | | `backend.auth.*` | 各欄位 | 認證設定(管理員信箱、JWT 金鑰) | +### 資料庫模式比較 + +| 特性 | MySQL 模式 | SQLite 模式 | +|------|------------|-------------| +| 網路需求 | 需連接遠端資料庫 | 完全離線運作 | +| 資料位置 | 雲端資料庫伺服器 | 本機檔案 | +| 多用戶共享 | ✅ 支援 | ❌ 僅單機使用 | +| 適用場景 | 企業部署、多人共用 | 離線環境、防火牆限制 | +| 資料備份 | 使用資料庫工具 | 複製 `.db` 檔案即可 | + +### Portable 執行檔解壓縮位置 + +Portable exe 執行時會解壓縮到 `%TEMP%\Meeting-Assistant` 資料夾(固定路徑,非隨機資料夾)。 + +- **優點**:Windows Defender 不會每次都提示警告 +- **SQLite 資料庫位置**:`%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db` + ### 啟動流程 1. 用戶雙擊 exe -2. Electron 主程序啟動 -3. 讀取 `config.json` -4. 啟動內嵌後端 (FastAPI) -5. 健康檢查等待後端就緒(最多 30 秒) -6. 後端就緒後,載入前端頁面 -7. 啟動 Whisper Sidecar -8. 應用就緒 +2. 解壓縮到 `%TEMP%\Meeting-Assistant` +3. Electron 主程序啟動 +4. 讀取 `config.json` +5. 啟動內嵌後端 (FastAPI) +6. 健康檢查等待後端就緒(最多 30 秒) +7. 後端就緒後,載入前端頁面 +8. 啟動 Whisper Sidecar +9. 應用就緒 ### 向後相容性 @@ -367,6 +442,8 @@ Copy `sidecar/dist/` to `client/sidecar/` before building Electron app. ## Database Setup +### MySQL 模式 + The backend will automatically create tables on first startup. To manually verify: ```sql @@ -374,7 +451,17 @@ USE your_database; SHOW TABLES LIKE 'meeting_%'; ``` -Expected tables: +### SQLite 模式 + +SQLite 資料庫檔案會在首次啟動時自動建立: + +- **開發環境**:`backend/data/meeting.db` +- **打包後**:`%TEMP%\Meeting-Assistant\resources\backend\backend\data\meeting.db` + +**備份方式**:直接複製 `.db` 檔案即可。 + +### Expected tables + - `meeting_users` - `meeting_records` - `meeting_conclusions` diff --git a/backend/app/config.py b/backend/app/config.py index fce494c..68642d2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,6 +21,8 @@ class Settings: BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000")) # Database Configuration + DB_TYPE: str = os.getenv("DB_TYPE", "mysql") # "mysql" or "sqlite" + SQLITE_PATH: str = os.getenv("SQLITE_PATH", "data/meeting.db") DB_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com") DB_PORT: int = int(os.getenv("DB_PORT", "33306")) DB_USER: str = os.getenv("DB_USER", "A060") @@ -90,6 +92,21 @@ class Settings: return os.path.join(base_dir, self.RECORD_DIR) return os.path.join(base_dir, "record") + def get_sqlite_path(self, base_dir: str | None = None) -> str: + """Get SQLite database file path, resolving relative paths. + + Args: + base_dir: Base directory for relative paths. If None, uses get_base_dir() + which supports frozen executables. + """ + if base_dir is None: + base_dir = get_base_dir() + if self.SQLITE_PATH: + if os.path.isabs(self.SQLITE_PATH): + return self.SQLITE_PATH + return os.path.join(base_dir, self.SQLITE_PATH) + return os.path.join(base_dir, "data", "meeting.db") + # Timeout helpers (convert ms to seconds for httpx) @property def upload_timeout_seconds(self) -> float: diff --git a/backend/app/database.py b/backend/app/database.py index ab0e0f7..f12b23a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,14 +1,59 @@ +""" +Database abstraction layer supporting both MySQL and SQLite. + +Usage: + from app.database import init_db, get_db_cursor, init_tables + + # At application startup + init_db() + init_tables() + + # In request handlers + with get_db_cursor() as cursor: + cursor.execute("SELECT * FROM meeting_records") + results = cursor.fetchall() + + with get_db_cursor(commit=True) as cursor: + cursor.execute("INSERT INTO ...") +""" + +import os +import sqlite3 +import threading +from contextlib import contextmanager + import mysql.connector from mysql.connector import pooling -from contextlib import contextmanager + from .config import settings -connection_pool = None +# Global state +_db_type: str = "mysql" +_mysql_pool = None +_sqlite_conn = None +_sqlite_lock = threading.Lock() -def init_db_pool(): - global connection_pool - connection_pool = pooling.MySQLConnectionPool( +# ============================================================================ +# Initialization Functions +# ============================================================================ + + +def init_db(): + """Initialize database based on DB_TYPE setting.""" + global _db_type + _db_type = settings.DB_TYPE.lower() + + if _db_type == "sqlite": + init_sqlite() + else: + init_mysql() + + +def init_mysql(): + """Initialize MySQL connection pool.""" + global _mysql_pool + _mysql_pool = pooling.MySQLConnectionPool( pool_name="meeting_pool", pool_size=settings.DB_POOL_SIZE, host=settings.DB_HOST, @@ -17,80 +62,250 @@ def init_db_pool(): password=settings.DB_PASS, database=settings.DB_NAME, ) - return connection_pool + return _mysql_pool + + +def init_sqlite(): + """Initialize SQLite connection with row_factory for dict-like access.""" + global _sqlite_conn + + db_path = settings.get_sqlite_path() + db_dir = os.path.dirname(db_path) + + # Create directory if needed + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + + _sqlite_conn = sqlite3.connect(db_path, check_same_thread=False) + _sqlite_conn.row_factory = sqlite3.Row + _sqlite_conn.execute("PRAGMA foreign_keys=ON") + + print(f"SQLite database initialized at: {db_path}", flush=True) + return _sqlite_conn + + +# ============================================================================ +# Legacy Compatibility +# ============================================================================ + +def init_db_pool(): + """Legacy function for backward compatibility. Use init_db() instead.""" + return init_db() + + +# ============================================================================ +# Connection Context Managers +# ============================================================================ @contextmanager def get_db_connection(): - conn = connection_pool.get_connection() - try: - yield conn - finally: - conn.close() + """Get a database connection (MySQL or SQLite).""" + if _db_type == "sqlite": + # SQLite uses a single connection with thread lock + yield _sqlite_conn + else: + # MySQL uses connection pool + conn = _mysql_pool.get_connection() + try: + yield conn + finally: + conn.close() + + +class SQLiteCursorWrapper: + """Wrapper to make SQLite cursor behave more like MySQL cursor with dictionary=True.""" + + def __init__(self, cursor): + self._cursor = cursor + self.lastrowid = None + self.rowcount = 0 + + def execute(self, query, params=None): + # Convert MySQL-style %s placeholders to SQLite ? placeholders + query = query.replace("%s", "?") + if params: + self._cursor.execute(query, params) + else: + self._cursor.execute(query) + self.lastrowid = self._cursor.lastrowid + self.rowcount = self._cursor.rowcount + + def executemany(self, query, params_list): + query = query.replace("%s", "?") + self._cursor.executemany(query, params_list) + self.lastrowid = self._cursor.lastrowid + self.rowcount = self._cursor.rowcount + + def fetchone(self): + row = self._cursor.fetchone() + if row is None: + return None + return dict(row) + + def fetchall(self): + rows = self._cursor.fetchall() + return [dict(row) for row in rows] + + def fetchmany(self, size=None): + if size: + rows = self._cursor.fetchmany(size) + else: + rows = self._cursor.fetchmany() + return [dict(row) for row in rows] + + def close(self): + self._cursor.close() @contextmanager def get_db_cursor(commit=False): - with get_db_connection() as conn: - cursor = conn.cursor(dictionary=True) - try: - yield cursor - if commit: - conn.commit() - finally: - cursor.close() + """Get a database cursor that returns dict-like rows. + + Args: + commit: If True, commit the transaction after yield. + + Yields: + cursor: A cursor that returns dict-like rows. + """ + if _db_type == "sqlite": + with _sqlite_lock: + cursor = SQLiteCursorWrapper(_sqlite_conn.cursor()) + try: + yield cursor + if commit: + _sqlite_conn.commit() + except Exception: + _sqlite_conn.rollback() + raise + finally: + cursor.close() + else: + with get_db_connection() as conn: + cursor = conn.cursor(dictionary=True) + try: + yield cursor + if commit: + conn.commit() + finally: + cursor.close() + + +# ============================================================================ +# Table Initialization +# ============================================================================ + +MYSQL_TABLES = [ + """ + CREATE TABLE IF NOT EXISTS meeting_users ( + user_id INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(100) UNIQUE NOT NULL, + display_name VARCHAR(50), + role ENUM('admin', 'user') DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_records ( + meeting_id INT PRIMARY KEY AUTO_INCREMENT, + uuid VARCHAR(64) UNIQUE, + subject VARCHAR(200) NOT NULL, + meeting_time DATETIME NOT NULL, + location VARCHAR(100), + chairperson VARCHAR(50), + recorder VARCHAR(50), + attendees TEXT, + transcript_blob LONGTEXT, + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_conclusions ( + conclusion_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, + system_code VARCHAR(20), + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_action_items ( + action_id INT PRIMARY KEY AUTO_INCREMENT, + meeting_id INT, + content TEXT, + owner VARCHAR(50), + due_date DATE, + status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open', + system_code VARCHAR(20), + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, +] + +SQLITE_TABLES = [ + """ + CREATE TABLE IF NOT EXISTS meeting_users ( + user_id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + display_name TEXT, + role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_records ( + meeting_id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE, + subject TEXT NOT NULL, + meeting_time DATETIME NOT NULL, + location TEXT, + chairperson TEXT, + recorder TEXT, + attendees TEXT, + transcript_blob TEXT, + created_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_conclusions ( + conclusion_id INTEGER PRIMARY KEY AUTOINCREMENT, + meeting_id INTEGER, + content TEXT, + system_code TEXT, + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS meeting_action_items ( + action_id INTEGER PRIMARY KEY AUTOINCREMENT, + meeting_id INTEGER, + content TEXT, + owner TEXT, + due_date DATE, + status TEXT CHECK(status IN ('Open', 'In Progress', 'Done', 'Delayed')) DEFAULT 'Open', + system_code TEXT, + FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE + ) + """, +] def init_tables(): """Create all required tables if they don't exist.""" - create_statements = [ - """ - CREATE TABLE IF NOT EXISTS meeting_users ( - user_id INT PRIMARY KEY AUTO_INCREMENT, - email VARCHAR(100) UNIQUE NOT NULL, - display_name VARCHAR(50), - role ENUM('admin', 'user') DEFAULT 'user', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """, - """ - CREATE TABLE IF NOT EXISTS meeting_records ( - meeting_id INT PRIMARY KEY AUTO_INCREMENT, - uuid VARCHAR(64) UNIQUE, - subject VARCHAR(200) NOT NULL, - meeting_time DATETIME NOT NULL, - location VARCHAR(100), - chairperson VARCHAR(50), - recorder VARCHAR(50), - attendees TEXT, - transcript_blob LONGTEXT, - created_by VARCHAR(100), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """, - """ - CREATE TABLE IF NOT EXISTS meeting_conclusions ( - conclusion_id INT PRIMARY KEY AUTO_INCREMENT, - meeting_id INT, - content TEXT, - system_code VARCHAR(20), - FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE - ) - """, - """ - CREATE TABLE IF NOT EXISTS meeting_action_items ( - action_id INT PRIMARY KEY AUTO_INCREMENT, - meeting_id INT, - content TEXT, - owner VARCHAR(50), - due_date DATE, - status ENUM('Open', 'In Progress', 'Done', 'Delayed') DEFAULT 'Open', - system_code VARCHAR(20), - FOREIGN KEY (meeting_id) REFERENCES meeting_records(meeting_id) ON DELETE CASCADE - ) - """, - ] + tables = SQLITE_TABLES if _db_type == "sqlite" else MYSQL_TABLES - with get_db_cursor(commit=True) as cursor: - for statement in create_statements: - cursor.execute(statement) + if _db_type == "sqlite": + with _sqlite_lock: + cursor = _sqlite_conn.cursor() + try: + for statement in tables: + cursor.execute(statement) + _sqlite_conn.commit() + finally: + cursor.close() + else: + with get_db_cursor(commit=True) as cursor: + for statement in tables: + cursor.execute(statement) diff --git a/backend/run_server.py b/backend/run_server.py index 9a85565..72fd2b9 100644 --- a/backend/run_server.py +++ b/backend/run_server.py @@ -47,6 +47,10 @@ def apply_config_to_env(config: dict) -> None: # Database configuration - use direct assignment to ensure config values are used db_config = backend_config.get("database", {}) + if "type" in db_config: + os.environ["DB_TYPE"] = db_config["type"] + if "sqlitePath" in db_config: + os.environ["SQLITE_PATH"] = db_config["sqlitePath"] if "host" in db_config: os.environ["DB_HOST"] = db_config["host"] if "port" in db_config: @@ -123,11 +127,13 @@ def main(): print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True) backend_config = config.get("backend", {}) db_config = backend_config.get("database", {}) + print(f"DEBUG: DB type={db_config.get('type', 'mysql')}", flush=True) print(f"DEBUG: DB config: host={db_config.get('host')}, user={db_config.get('user')}, pass={'***' if db_config.get('password') else 'EMPTY'}", flush=True) apply_config_to_env(config) # Debug: print env vars after setting + print(f"DEBUG: ENV DB_TYPE={os.environ.get('DB_TYPE', 'mysql')}", flush=True) print(f"DEBUG: ENV DB_HOST={os.environ.get('DB_HOST')}", flush=True) print(f"DEBUG: ENV DB_USER={os.environ.get('DB_USER')}", flush=True) print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True) diff --git a/client/config.json b/client/config.json index 9ada2a7..47f9d82 100644 --- a/client/config.json +++ b/client/config.json @@ -12,6 +12,8 @@ "host": "127.0.0.1", "port": 8000, "database": { + "type": "mysql", + "sqlitePath": "data/meeting.db", "host": "mysql.theaken.com", "port": 33306, "user": "A060", diff --git a/client/package.json b/client/package.json index 9ec1d55..bb07c1c 100644 --- a/client/package.json +++ b/client/package.json @@ -72,7 +72,8 @@ "icon": "assets/icon.png" }, "portable": { - "artifactName": "${productName}-${version}-portable.${ext}" + "artifactName": "${productName}-${version}-portable.${ext}", + "unpackDirName": "Meeting-Assistant" } } } diff --git a/scripts/build-client.bat b/scripts/build-client.bat index b0b1fbb..2967393 100644 --- a/scripts/build-client.bat +++ b/scripts/build-client.bat @@ -27,6 +27,7 @@ set "SKIP_BACKEND=true" set "EMBEDDED_BACKEND=false" set "CLEAN_BUILD=false" set "API_URL=" +set "DATABASE_TYPE=" REM 解析參數 set "COMMAND=help" @@ -42,6 +43,7 @@ if /i "%~1"=="--skip-backend" (set "SKIP_BACKEND=true" & shift & goto :parse_arg if /i "%~1"=="--embedded-backend" (set "EMBEDDED_BACKEND=true" & set "SKIP_BACKEND=false" & shift & goto :parse_args) if /i "%~1"=="--clean" (set "CLEAN_BUILD=true" & shift & goto :parse_args) if /i "%~1"=="--api-url" (set "API_URL=%~2" & shift & shift & goto :parse_args) +if /i "%~1"=="--database-type" (set "DATABASE_TYPE=%~2" & shift & shift & goto :parse_args) echo %RED%[ERROR]%NC% 未知參數: %~1 goto :show_help @@ -324,6 +326,42 @@ if errorlevel 1 ( echo %GREEN%[OK]%NC% 已啟用內嵌後端模式 goto :eof +:update_config_database +REM 更新 config.json 的資料庫類型 +if "%DATABASE_TYPE%"=="" goto :eof + +echo %BLUE%[STEP]%NC% 設定資料庫類型... + +set "CONFIG_FILE=%CLIENT_DIR%\config.json" +if not exist "%CONFIG_FILE%" ( + echo %YELLOW%[WARN]%NC% 找不到 config.json,跳過資料庫類型設定 + goto :eof +) + +REM 驗證資料庫類型 +if /i not "%DATABASE_TYPE%"=="mysql" if /i not "%DATABASE_TYPE%"=="sqlite" ( + echo %RED%[ERROR]%NC% 無效的資料庫類型: %DATABASE_TYPE% + echo %BLUE%[INFO]%NC% 有效選項: mysql, sqlite + exit /b 1 +) + +REM 使用 PowerShell 更新 database.type (使用 UTF8 without BOM) +if /i "%DATABASE_TYPE%"=="sqlite" ( + REM SQLite 模式: 設定 type=sqlite,清空 MySQL 連線資訊 + powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'sqlite'; $config.backend.database.host = ''; $config.backend.database.user = ''; $config.backend.database.password = ''; $config.backend.database.database = ''; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))" + echo %GREEN%[OK]%NC% 資料庫類型已設定為: SQLite (本地模式) +) else ( + REM MySQL 模式: 僅設定 type=mysql,保留連線資訊 + powershell -Command "$config = Get-Content '%CONFIG_FILE%' -Raw | ConvertFrom-Json; $config.backend.database.type = 'mysql'; $json = $config | ConvertTo-Json -Depth 10; [System.IO.File]::WriteAllText('%CONFIG_FILE%', $json, [System.Text.UTF8Encoding]::new($false))" + echo %GREEN%[OK]%NC% 資料庫類型已設定為: MySQL (雲端模式) +) + +if errorlevel 1 ( + echo %RED%[ERROR]%NC% 更新 config.json database.type 失敗 + exit /b 1 +) +goto :eof + :setup_client echo %BLUE%[STEP]%NC% 設置前端建置環境... @@ -436,6 +474,9 @@ call :update_config REM 更新 embedded backend 設定(如果有指定) call :update_config_embedded +REM 更新資料庫類型設定(如果有指定) +call :update_config_database + if "%SKIP_SIDECAR%"=="false" ( call :setup_sidecar_venv call :build_sidecar @@ -506,11 +547,13 @@ echo --api-url URL 後端 API URL (預設: http://localhost:8000/api) echo --skip-sidecar 跳過 Sidecar 打包 echo --skip-backend 跳過 Backend 打包 (預設) echo --embedded-backend 打包內嵌後端 (全包部署模式) +echo --database-type TYPE 資料庫類型: mysql (雲端) 或 sqlite (本地) echo --clean 建置前先清理 echo. echo 範例: echo %~nx0 build 完整建置 (前端+Sidecar) echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端) +echo %~nx0 build --embedded-backend --database-type sqlite 全包部署 + SQLite echo %~nx0 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端 echo %~nx0 sidecar 僅打包 Sidecar echo %~nx0 electron --skip-sidecar 僅打包 Electron @@ -519,6 +562,10 @@ echo 部署模式: echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址 echo 全包部署: 使用 --embedded-backend 將後端打包進 exe,雙擊即可運行 echo. +echo 資料庫模式: +echo MySQL(預設): 連接雲端資料庫,需要網路存取 +echo SQLite: 本地資料庫,適合離線或防火牆環境,使用 --database-type sqlite +echo. echo 注意: echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間 echo - 全包部署需要額外約 50MB 空間用於後端