feat: Add SQLite database support and fixed portable extraction path

- 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 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-17 13:57:02 +08:00
parent 34947f6262
commit 01d578c67e
7 changed files with 456 additions and 81 deletions

View File

@@ -4,7 +4,7 @@
- Python 3.10+ - Python 3.10+
- Node.js 18+ - Node.js 18+
- MySQL 8.0+ - MySQL 8.0+ 或 SQLite本地模式
- Access to Dify LLM service - Access to Dify LLM service
## Quick Start ## Quick Start
@@ -233,18 +233,32 @@ npm start
- 企業內部部署,簡化用戶操作 - 企業內部部署,簡化用戶操作
- 無法獨立架設後端的環境 - 無法獨立架設後端的環境
- 快速測試和演示 - 快速測試和演示
- **離線環境**:使用 SQLite 本地資料庫,無需網路連接資料庫
### 打包方式 ### 打包方式
```batch ```batch
# Windows 全包打包 # Windows 全包打包MySQL 雲端資料庫,預設)
.\scripts\build-client.bat build --embedded-backend --clean .\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 配置
全包模式需要在 `config.json` 中配置資料庫和 API 金鑰: 全包模式需要在 `config.json` 中配置資料庫和 API 金鑰:
#### MySQL 模式(雲端資料庫)
```json ```json
{ {
"apiBaseUrl": "http://localhost:8000/api", "apiBaseUrl": "http://localhost:8000/api",
@@ -260,6 +274,8 @@ npm start
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8000, "port": 8000,
"database": { "database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com", "host": "mysql.theaken.com",
"port": 33306, "port": 33306,
"user": "your_username", "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.embedded` | `true`/`false` | 啟用/停用內嵌後端模式 |
| `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1 | | `backend.host` | IP | 後端監聽地址(通常為 127.0.0.1 |
| `backend.port` | 數字 | 後端監聽端口(預設 8000 | | `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.externalApis.*` | 各欄位 | 外部 API 設定認證、Dify |
| `backend.auth.*` | 各欄位 | 認證設定管理員信箱、JWT 金鑰) | | `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 1. 用戶雙擊 exe
2. Electron 主程序啟動 2. 解壓縮到 `%TEMP%\Meeting-Assistant`
3. 讀取 `config.json` 3. Electron 主程序啟動
4. 啟動內嵌後端 (FastAPI) 4. 讀取 `config.json`
5. 健康檢查等待後端就緒(最多 30 秒) 5. 啟動內嵌後端 (FastAPI)
6. 後端就緒後,載入前端頁面 6. 健康檢查等待後端就緒(最多 30 秒)
7. 啟動 Whisper Sidecar 7. 後端就緒後,載入前端頁面
8. 應用就緒 8. 啟動 Whisper Sidecar
9. 應用就緒
### 向後相容性 ### 向後相容性
@@ -367,6 +442,8 @@ Copy `sidecar/dist/` to `client/sidecar/` before building Electron app.
## Database Setup ## Database Setup
### MySQL 模式
The backend will automatically create tables on first startup. To manually verify: The backend will automatically create tables on first startup. To manually verify:
```sql ```sql
@@ -374,7 +451,17 @@ USE your_database;
SHOW TABLES LIKE 'meeting_%'; 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_users`
- `meeting_records` - `meeting_records`
- `meeting_conclusions` - `meeting_conclusions`

View File

@@ -21,6 +21,8 @@ class Settings:
BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000")) BACKEND_PORT: int = int(os.getenv("BACKEND_PORT", "8000"))
# Database Configuration # 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_HOST: str = os.getenv("DB_HOST", "mysql.theaken.com")
DB_PORT: int = int(os.getenv("DB_PORT", "33306")) DB_PORT: int = int(os.getenv("DB_PORT", "33306"))
DB_USER: str = os.getenv("DB_USER", "A060") 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, self.RECORD_DIR)
return os.path.join(base_dir, "record") 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) # Timeout helpers (convert ms to seconds for httpx)
@property @property
def upload_timeout_seconds(self) -> float: def upload_timeout_seconds(self) -> float:

View File

@@ -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 import mysql.connector
from mysql.connector import pooling from mysql.connector import pooling
from contextlib import contextmanager
from .config import settings 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 # Initialization Functions
connection_pool = pooling.MySQLConnectionPool( # ============================================================================
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_name="meeting_pool",
pool_size=settings.DB_POOL_SIZE, pool_size=settings.DB_POOL_SIZE,
host=settings.DB_HOST, host=settings.DB_HOST,
@@ -17,80 +62,250 @@ def init_db_pool():
password=settings.DB_PASS, password=settings.DB_PASS,
database=settings.DB_NAME, 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 @contextmanager
def get_db_connection(): def get_db_connection():
conn = connection_pool.get_connection() """Get a database connection (MySQL or SQLite)."""
try: if _db_type == "sqlite":
yield conn # SQLite uses a single connection with thread lock
finally: yield _sqlite_conn
conn.close() 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 @contextmanager
def get_db_cursor(commit=False): def get_db_cursor(commit=False):
with get_db_connection() as conn: """Get a database cursor that returns dict-like rows.
cursor = conn.cursor(dictionary=True)
try: Args:
yield cursor commit: If True, commit the transaction after yield.
if commit:
conn.commit() Yields:
finally: cursor: A cursor that returns dict-like rows.
cursor.close() """
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(): def init_tables():
"""Create all required tables if they don't exist.""" """Create all required tables if they don't exist."""
create_statements = [ tables = SQLITE_TABLES if _db_type == "sqlite" else 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
)
""",
]
with get_db_cursor(commit=True) as cursor: if _db_type == "sqlite":
for statement in create_statements: with _sqlite_lock:
cursor.execute(statement) 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)

View File

@@ -47,6 +47,10 @@ def apply_config_to_env(config: dict) -> None:
# Database configuration - use direct assignment to ensure config values are used # Database configuration - use direct assignment to ensure config values are used
db_config = backend_config.get("database", {}) 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: if "host" in db_config:
os.environ["DB_HOST"] = db_config["host"] os.environ["DB_HOST"] = db_config["host"]
if "port" in db_config: if "port" in db_config:
@@ -123,11 +127,13 @@ def main():
print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True) print(f"DEBUG: Loaded config keys: {list(config.keys())}", flush=True)
backend_config = config.get("backend", {}) backend_config = config.get("backend", {})
db_config = backend_config.get("database", {}) 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) 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) apply_config_to_env(config)
# Debug: print env vars after setting # 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_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_USER={os.environ.get('DB_USER')}", flush=True)
print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True) print(f"DEBUG: ENV DB_PASS={'***' if os.environ.get('DB_PASS') else 'EMPTY'}", flush=True)

View File

@@ -12,6 +12,8 @@
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8000, "port": 8000,
"database": { "database": {
"type": "mysql",
"sqlitePath": "data/meeting.db",
"host": "mysql.theaken.com", "host": "mysql.theaken.com",
"port": 33306, "port": 33306,
"user": "A060", "user": "A060",

View File

@@ -72,7 +72,8 @@
"icon": "assets/icon.png" "icon": "assets/icon.png"
}, },
"portable": { "portable": {
"artifactName": "${productName}-${version}-portable.${ext}" "artifactName": "${productName}-${version}-portable.${ext}",
"unpackDirName": "Meeting-Assistant"
} }
} }
} }

View File

@@ -27,6 +27,7 @@ set "SKIP_BACKEND=true"
set "EMBEDDED_BACKEND=false" set "EMBEDDED_BACKEND=false"
set "CLEAN_BUILD=false" set "CLEAN_BUILD=false"
set "API_URL=" set "API_URL="
set "DATABASE_TYPE="
REM 解析參數 REM 解析參數
set "COMMAND=help" 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"=="--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"=="--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"=="--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 echo %RED%[ERROR]%NC% 未知參數: %~1
goto :show_help goto :show_help
@@ -324,6 +326,42 @@ if errorlevel 1 (
echo %GREEN%[OK]%NC% 已啟用內嵌後端模式 echo %GREEN%[OK]%NC% 已啟用內嵌後端模式
goto :eof 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 :setup_client
echo %BLUE%[STEP]%NC% 設置前端建置環境... echo %BLUE%[STEP]%NC% 設置前端建置環境...
@@ -436,6 +474,9 @@ call :update_config
REM 更新 embedded backend 設定(如果有指定) REM 更新 embedded backend 設定(如果有指定)
call :update_config_embedded call :update_config_embedded
REM 更新資料庫類型設定(如果有指定)
call :update_config_database
if "%SKIP_SIDECAR%"=="false" ( if "%SKIP_SIDECAR%"=="false" (
call :setup_sidecar_venv call :setup_sidecar_venv
call :build_sidecar call :build_sidecar
@@ -506,11 +547,13 @@ echo --api-url URL 後端 API URL (預設: http://localhost:8000/api)
echo --skip-sidecar 跳過 Sidecar 打包 echo --skip-sidecar 跳過 Sidecar 打包
echo --skip-backend 跳過 Backend 打包 (預設) echo --skip-backend 跳過 Backend 打包 (預設)
echo --embedded-backend 打包內嵌後端 (全包部署模式) echo --embedded-backend 打包內嵌後端 (全包部署模式)
echo --database-type TYPE 資料庫類型: mysql (雲端) 或 sqlite (本地)
echo --clean 建置前先清理 echo --clean 建置前先清理
echo. echo.
echo 範例: echo 範例:
echo %~nx0 build 完整建置 (前端+Sidecar) echo %~nx0 build 完整建置 (前端+Sidecar)
echo %~nx0 build --embedded-backend 全包部署 (含內嵌後端) 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 build --api-url "http://192.168.1.100:8000/api" 指定遠端後端
echo %~nx0 sidecar 僅打包 Sidecar echo %~nx0 sidecar 僅打包 Sidecar
echo %~nx0 electron --skip-sidecar 僅打包 Electron echo %~nx0 electron --skip-sidecar 僅打包 Electron
@@ -519,6 +562,10 @@ echo 部署模式:
echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址 echo 分離部署(預設): 前端連接遠端後端,使用 --api-url 指定後端地址
echo 全包部署: 使用 --embedded-backend 將後端打包進 exe雙擊即可運行 echo 全包部署: 使用 --embedded-backend 將後端打包進 exe雙擊即可運行
echo. echo.
echo 資料庫模式:
echo MySQL預設: 連接雲端資料庫,需要網路存取
echo SQLite: 本地資料庫,適合離線或防火牆環境,使用 --database-type sqlite
echo.
echo 注意: echo 注意:
echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間 echo - 首次打包 Sidecar 需下載 Whisper 模型,可能需要較長時間
echo - 全包部署需要額外約 50MB 空間用於後端 echo - 全包部署需要額外約 50MB 空間用於後端