fix: resolve TypeScript compilation errors
Fixes: - VideoPlayer.vue: restore allTags, filteredMarks, marksByTag (removed by mistake) - PeopleView.vue: fix frameTime scope in playFaceDetailVideo catch block - Both errors caused white screen on build failure Root cause: 1. visibleTags duplicate declaration (old + new logic) 2. frameTime undefined in catch block (scope issue) Solution: 1. Keep allTags/filteredMarks/marksByTag (needed in template) 2. Keep visibleTags (new logic for processor toggles) 3. Move frameTime calculation to correct scope in catch block
This commit is contained in:
50
DEPLOYMENT_TEST_REPORT.md
Normal file
50
DEPLOYMENT_TEST_REPORT.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Admin + Client 部署到 201 测试报告
|
||||
|
||||
## 部署时间:2026-06-26 16:25
|
||||
|
||||
## 已部署文件
|
||||
|
||||
| 文件 | 状态 | 大小 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **LoginView.vue** | ✅ 已复制 | 3761 bytes | 登录界面 ⭐⭐⭐⭐⭐ |
|
||||
| **ClientView.vue** | ✅ 已复制 | 17240 bytes | 文件管理界面 ⭐⭐⭐⭐⭐ |
|
||||
| **router/index.ts** | ✅ 已更新 | - | 添加 /login 路由 |
|
||||
| **App.vue** | ⏳ 待更新 | - | 需添加路由守卫 |
|
||||
|
||||
## 路由配置 ✅
|
||||
|
||||
- → LoginView.vue ✅
|
||||
- → AdminView.vue ✅
|
||||
- → ClientView.vue ✅
|
||||
|
||||
## Tauri Commands ✅
|
||||
|
||||
已存在:
|
||||
- ✅
|
||||
- ✅
|
||||
- ✅
|
||||
- ✅
|
||||
|
||||
## 测试结果 ⚠️
|
||||
|
||||
### Vite dev server
|
||||
- 状态:运行中
|
||||
- 问题:node 命令找不到
|
||||
- 解决方案:需要使用绝对路径
|
||||
|
||||
### Proxy (port 8888)
|
||||
- 状态:运行中
|
||||
- 问题:前端路由返回 "Not found"
|
||||
- 原因:Vite dev server 未正确启动
|
||||
|
||||
## 下一步 ⭐⭐⭐⭐⭐
|
||||
|
||||
1. **修复 Vite 启动**:使用绝对路径
|
||||
2. **添加 App.vue 路由守卫**:检查 token 并跳转
|
||||
3. **测试完整功能**:登录 + Client 文件管理
|
||||
4. **验证 Tauri commands**:文件列表/上传/下载
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-26 16:25
|
||||
**状态**: 文件已部署,等待 Vite 重启 ⭐⭐⭐⭐
|
||||
BIN
data/users/demo.sqlite
Normal file
BIN
data/users/demo.sqlite
Normal file
Binary file not shown.
BIN
data/users/demo.sqlite-shm
Normal file
BIN
data/users/demo.sqlite-shm
Normal file
Binary file not shown.
1
dist/assets/index-B7dM6B6s.css
vendored
1
dist/assets/index-B7dM6B6s.css
vendored
File diff suppressed because one or more lines are too long
25
dist/assets/index-BXXbgmgw.js
vendored
25
dist/assets/index-BXXbgmgw.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Momentry Studio</title>
|
||||
<script type="module" crossorigin src="/assets/index-BXXbgmgw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7dM6B6s.css">
|
||||
<script type="module" crossorigin src="/assets/index-XYmskk93.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D8U_cUr3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
109
node_modules/.package-lock.json
generated
vendored
109
node_modules/.package-lock.json
generated
vendored
@@ -67,6 +67,50 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "9.14.5",
|
||||
"@intlify/shared": "9.14.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "9.14.5",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -104,6 +148,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@@ -289,6 +339,44 @@
|
||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.3.0",
|
||||
"@vueuse/shared": "14.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||
@@ -721,6 +809,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "9.14.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "9.14.5",
|
||||
"@intlify/shared": "9.14.5",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
29
node_modules/.vite/deps/_metadata.json
generated
vendored
29
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,29 +1,44 @@
|
||||
{
|
||||
"hash": "4fedd820",
|
||||
"configHash": "912a7b44",
|
||||
"lockfileHash": "8417729c",
|
||||
"browserHash": "686c2332",
|
||||
"hash": "813ccdf0",
|
||||
"configHash": "5b9e4b03",
|
||||
"lockfileHash": "ac269d47",
|
||||
"browserHash": "2eabb9c1",
|
||||
"optimized": {
|
||||
"@tauri-apps/api/core": {
|
||||
"src": "../../@tauri-apps/api/core.js",
|
||||
"file": "@tauri-apps_api_core.js",
|
||||
"fileHash": "e45d7d78",
|
||||
"fileHash": "3969e853",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue": {
|
||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||
"file": "vue.js",
|
||||
"fileHash": "7f9396bc",
|
||||
"fileHash": "b283492a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-router": {
|
||||
"src": "../../vue-router/dist/vue-router.mjs",
|
||||
"file": "vue-router.js",
|
||||
"fileHash": "d8343f48",
|
||||
"fileHash": "0528c0df",
|
||||
"needsInterop": false
|
||||
},
|
||||
"vue-i18n": {
|
||||
"src": "../../vue-i18n/dist/vue-i18n.mjs",
|
||||
"file": "vue-i18n.js",
|
||||
"fileHash": "b2bd439f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@vueuse/core": {
|
||||
"src": "../../@vueuse/core/dist/index.js",
|
||||
"file": "@vueuse_core.js",
|
||||
"fileHash": "db16229f",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-YFT6OQ5R": {
|
||||
"file": "chunk-YFT6OQ5R.js"
|
||||
},
|
||||
"chunk-5G6OTI3J": {
|
||||
"file": "chunk-5G6OTI3J.js"
|
||||
}
|
||||
|
||||
161
node_modules/.vite/deps/vue-router.js
generated
vendored
161
node_modules/.vite/deps/vue-router.js
generated
vendored
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
setupDevtoolsPlugin
|
||||
} from "./chunk-YFT6OQ5R.js";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
@@ -18,164 +21,6 @@ import {
|
||||
watchEffect
|
||||
} from "./chunk-5G6OTI3J.js";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/env.js
|
||||
function getDevtoolsGlobalHook() {
|
||||
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
||||
}
|
||||
function getTarget() {
|
||||
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
|
||||
}
|
||||
var isProxyAvailable = typeof Proxy === "function";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/const.js
|
||||
var HOOK_SETUP = "devtools-plugin:setup";
|
||||
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/time.js
|
||||
var supported;
|
||||
var perf;
|
||||
function isPerformanceSupported() {
|
||||
var _a;
|
||||
if (supported !== void 0) {
|
||||
return supported;
|
||||
}
|
||||
if (typeof window !== "undefined" && window.performance) {
|
||||
supported = true;
|
||||
perf = window.performance;
|
||||
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
|
||||
supported = true;
|
||||
perf = globalThis.perf_hooks.performance;
|
||||
} else {
|
||||
supported = false;
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
function now() {
|
||||
return isPerformanceSupported() ? perf.now() : Date.now();
|
||||
}
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/proxy.js
|
||||
var ApiProxy = class {
|
||||
constructor(plugin, hook) {
|
||||
this.target = null;
|
||||
this.targetQueue = [];
|
||||
this.onQueue = [];
|
||||
this.plugin = plugin;
|
||||
this.hook = hook;
|
||||
const defaultSettings = {};
|
||||
if (plugin.settings) {
|
||||
for (const id in plugin.settings) {
|
||||
const item = plugin.settings[id];
|
||||
defaultSettings[id] = item.defaultValue;
|
||||
}
|
||||
}
|
||||
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
|
||||
let currentSettings = Object.assign({}, defaultSettings);
|
||||
try {
|
||||
const raw = localStorage.getItem(localSettingsSaveId);
|
||||
const data = JSON.parse(raw);
|
||||
Object.assign(currentSettings, data);
|
||||
} catch (e) {
|
||||
}
|
||||
this.fallbacks = {
|
||||
getSettings() {
|
||||
return currentSettings;
|
||||
},
|
||||
setSettings(value) {
|
||||
try {
|
||||
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
}
|
||||
currentSettings = value;
|
||||
},
|
||||
now() {
|
||||
return now();
|
||||
}
|
||||
};
|
||||
if (hook) {
|
||||
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
|
||||
if (pluginId === this.plugin.id) {
|
||||
this.fallbacks.setSettings(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.proxiedOn = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target.on[prop];
|
||||
} else {
|
||||
return (...args) => {
|
||||
this.onQueue.push({
|
||||
method: prop,
|
||||
args
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
this.proxiedTarget = new Proxy({}, {
|
||||
get: (_target, prop) => {
|
||||
if (this.target) {
|
||||
return this.target[prop];
|
||||
} else if (prop === "on") {
|
||||
return this.proxiedOn;
|
||||
} else if (Object.keys(this.fallbacks).includes(prop)) {
|
||||
return (...args) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve: () => {
|
||||
}
|
||||
});
|
||||
return this.fallbacks[prop](...args);
|
||||
};
|
||||
} else {
|
||||
return (...args) => {
|
||||
return new Promise((resolve) => {
|
||||
this.targetQueue.push({
|
||||
method: prop,
|
||||
args,
|
||||
resolve
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setRealTarget(target) {
|
||||
this.target = target;
|
||||
for (const item of this.onQueue) {
|
||||
this.target.on[item.method](...item.args);
|
||||
}
|
||||
for (const item of this.targetQueue) {
|
||||
item.resolve(await this.target[item.method](...item.args));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// node_modules/@vue/devtools-api/lib/esm/index.js
|
||||
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
|
||||
const descriptor = pluginDescriptor;
|
||||
const target = getTarget();
|
||||
const hook = getDevtoolsGlobalHook();
|
||||
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
|
||||
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
|
||||
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
|
||||
} else {
|
||||
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
|
||||
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
|
||||
list.push({
|
||||
pluginDescriptor: descriptor,
|
||||
setupFn,
|
||||
proxy
|
||||
});
|
||||
if (proxy) {
|
||||
setupFn(proxy.proxiedTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs
|
||||
var isBrowser = typeof document !== "undefined";
|
||||
function isRouteComponent(component) {
|
||||
|
||||
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
File diff suppressed because one or more lines are too long
73
setup-gitea-ci.sh
Executable file
73
setup-gitea-ci.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
GITEA_URL="http://localhost:3000"
|
||||
REPO_NAME="momentry_studio"
|
||||
|
||||
echo "========================================="
|
||||
echo " 設定 Gitea CI/CD"
|
||||
echo "========================================="
|
||||
|
||||
# 檢查 Token
|
||||
if [ -z "$LOCAL_GITEA_TOKEN" ]; then
|
||||
echo "❌ 請先設定 LOCAL_GITEA_TOKEN"
|
||||
echo " export LOCAL_GITEA_TOKEN=\"your-token\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Token 已設定"
|
||||
|
||||
# 1. 建立倉庫
|
||||
echo ""
|
||||
echo "📁 Step 1: 建立 Gitea 倉庫..."
|
||||
REPO_EXISTS=$(curl -s -H "Authorization: token $LOCAL_GITEA_TOKEN" "$GITEA_URL/api/v1/repos/admin/$REPO_NAME" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$REPO_EXISTS" ]; then
|
||||
curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$REPO_NAME\",\"private\":false,\"auto_init\":false}"
|
||||
echo "✅ 倉庫已建立"
|
||||
else
|
||||
echo "倉庫已存在"
|
||||
fi
|
||||
|
||||
# 2. 設定 Git 遠端
|
||||
echo ""
|
||||
echo "🔗 Step 2: 設定 Git 遠端..."
|
||||
git remote remove gitea 2>/dev/null || true
|
||||
git remote add gitea "$GITEA_URL/admin/$REPO_NAME.git"
|
||||
echo "✅ 遠端已設定"
|
||||
|
||||
# 3. 推送程式碼
|
||||
echo ""
|
||||
echo "📤 Step 3: 推送程式碼..."
|
||||
git push -u gitea main -f 2>&1 | tail -3
|
||||
echo "✅ 程式碼已推送"
|
||||
|
||||
# 4. 建立 Webhook
|
||||
echo ""
|
||||
echo "🔔 Step 4: 建立 Webhook..."
|
||||
curl -s -X POST "$GITEA_URL/api/v1/repos/admin/$REPO_NAME/hooks" \
|
||||
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"gitea\",
|
||||
\"active\": true,
|
||||
\"config\": {
|
||||
\"content_type\": \"json\",
|
||||
\"url\": \"http://localhost:8080/webhook\",
|
||||
\"is_insecure_ssl\": false
|
||||
},
|
||||
\"events\": [\"push\"]
|
||||
}" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Webhook ID: {d.get(\"id\",\"?\")}' 2>/dev/null || echo 'Webhook 建立失敗')" || echo "Webhook 需要 CI Server"
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " ✅ Gitea CI/CD 設定完成"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "倉庫網址: $GITEA_URL/admin/$REPO_NAME"
|
||||
echo ""
|
||||
echo "手動觸發 CI/CD:"
|
||||
echo " ./ci-cd.sh"
|
||||
12
src-tauri/src/bin/gen_password.rs
Normal file
12
src-tauri/src/bin/gen_password.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
|
||||
fn main() {
|
||||
let password = std::env::args().nth(1).unwrap_or_else(|| "demo123".to_string());
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||
Ok(hash) => println!("{}", hash),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
1177
src-tauri/src/main.rs.bak3
Normal file
1177
src-tauri/src/main.rs.bak3
Normal file
File diff suppressed because it is too large
Load Diff
1177
src-tauri/src/main.rs.base
Normal file
1177
src-tauri/src/main.rs.base
Normal file
File diff suppressed because it is too large
Load Diff
1177
src-tauri/src/main.rs.clean
Normal file
1177
src-tauri/src/main.rs.clean
Normal file
File diff suppressed because it is too large
Load Diff
1180
src-tauri/src/main.rs.work
Normal file
1180
src-tauri/src/main.rs.work
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because one or more lines are too long
99
src/App.vue.backup_final
Normal file
99
src/App.vue.backup_final
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||
<span>Search</span>
|
||||
</router-link>
|
||||
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||
<span>Library</span>
|
||||
</router-link>
|
||||
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||
<span>People</span>
|
||||
</router-link>
|
||||
<router-link to="/admin" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span>Admin</span>
|
||||
</router-link>
|
||||
<router-link to="/client" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>Client</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-theme-switcher">
|
||||
<button class="gs-theme-btn active">☀️</button>
|
||||
<button class="gs-theme-btn">🌙</button>
|
||||
<button class="gs-theme-btn">🌗</button>
|
||||
</div>
|
||||
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const appZoom = ref(100)
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault()
|
||||
appZoom.value = Math.min(appZoom.value + 10, 300)
|
||||
document.documentElement.style.zoom = appZoom.value + '%'
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault()
|
||||
appZoom.value = Math.max(appZoom.value - 10, 50)
|
||||
document.documentElement.style.zoom = appZoom.value + '%'
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
appZoom.value = 100
|
||||
document.documentElement.style.zoom = '100%'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import './assets/wordpress-exact.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
|
||||
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
|
||||
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
|
||||
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
||||
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
|
||||
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
|
||||
.gs-theme-switcher { display: flex; gap: 5px; }
|
||||
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
|
||||
.gs-theme-btn:hover { transform: scale(1.08); }
|
||||
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
|
||||
.gs-account-name { font-size: 12px; color: #5f6368; }
|
||||
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||
.ms-content { max-width: 1200px; }
|
||||
</style>
|
||||
33
src/api/params.ts
Normal file
33
src/api/params.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PARAM_MAP: Record<string, string> = {
|
||||
pageSize: 'page_size',
|
||||
perPage: 'per_page',
|
||||
fileUuid: 'file_uuid',
|
||||
startTime: 'start_time',
|
||||
endTime: 'end_time',
|
||||
startFrame: 'start_frame',
|
||||
endFrame: 'end_frame',
|
||||
bboxX: 'bbox_x',
|
||||
bboxY: 'bbox_y',
|
||||
bboxW: 'bbox_w',
|
||||
bboxH: 'bbox_h',
|
||||
conversationId: 'conversation_id',
|
||||
historyId: 'history_id',
|
||||
mergeId: 'merge_id',
|
||||
traceId: 'trace_id',
|
||||
faceId: 'face_id',
|
||||
}
|
||||
|
||||
export function toSnakeCase(key: string): string {
|
||||
if (PARAM_MAP[key]) return PARAM_MAP[key]
|
||||
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
export function mapParams(params: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[toSnakeCase(key)] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
26
src/api/types.ts
Normal file
26
src/api/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface PoseKeypoint {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export type PoseClass = 'standing' | 'sitting' | 'kneeling' | 'lying' | 'prone' | 'unknown'
|
||||
|
||||
export interface PoseResponse {
|
||||
frame: number
|
||||
keypoints: PoseKeypoint[]
|
||||
pose_class: PoseClass
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface DominantColor {
|
||||
rgb: [number, number, number]
|
||||
percentage?: number
|
||||
}
|
||||
|
||||
export interface AppearanceResponse {
|
||||
frame: number
|
||||
dominant_colors: DominantColor[]
|
||||
hsv_histogram?: number[][][]
|
||||
}
|
||||
216
src/components/AgentTip.vue
Normal file
216
src/components/AgentTip.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<div class="agent-tip" v-if="showAgentTip">
|
||||
<div class="agent-avatar">🤖</div>
|
||||
<div class="agent-message">{{ agentTip }}</div>
|
||||
<button class="agent-close" @click="showAgentTip = false">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="discovery-tip" v-if="discoveryTip">
|
||||
<span class="discovery-icon">✨</span>
|
||||
<span class="discovery-message">{{ discoveryTip }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { showAgentTip, agentTip, discoveryTip } from '@/stores/agentStore'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-tip {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
|
||||
z-index: 9999;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.agent-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agent-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.agent-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.discovery-tip {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #0ea5e9;
|
||||
color: #0c4a6e;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 9998;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.discovery-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.welcome-guide {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.guide-content {
|
||||
background: white;
|
||||
padding: 32px 40px;
|
||||
border-radius: 16px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.guide-content h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.guide-content > p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guide-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guide-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guide-content-text h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.guide-content-text p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.guide-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.guide-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.guide-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
animation: slideOutDown 0.3s ease-in;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutDown {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -419,7 +419,6 @@ function getTagColor(tag: string): string {
|
||||
}
|
||||
|
||||
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
|
||||
const visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
|
||||
|
||||
const filteredMarks = computed(() => {
|
||||
if (!tagFilter.value) return marks.value
|
||||
|
||||
12
src/composables/useTheme.ts
Normal file
12
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
export const isDark = useDark({
|
||||
selector: 'html',
|
||||
attribute: 'data-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
})
|
||||
|
||||
export const toggleDark = () => {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
35
src/composables/useZIndex.ts
Normal file
35
src/composables/useZIndex.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
let baseZIndex = 10000
|
||||
const stack = ref<string[]>([])
|
||||
|
||||
export function useZIndex() {
|
||||
const push = (id: string): number => {
|
||||
if (!stack.value.includes(id)) {
|
||||
stack.value.push(id)
|
||||
}
|
||||
return baseZIndex + stack.value.indexOf(id) * 10
|
||||
}
|
||||
|
||||
const pop = (id: string) => {
|
||||
const idx = stack.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
stack.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const current = computed(() => baseZIndex + stack.value.length * 10)
|
||||
|
||||
return { push, pop, current }
|
||||
}
|
||||
|
||||
let globalZIndex = 10000
|
||||
|
||||
export function getNextZIndex(): number {
|
||||
globalZIndex += 10
|
||||
return globalZIndex
|
||||
}
|
||||
|
||||
export function resetZIndex() {
|
||||
globalZIndex = 10000
|
||||
}
|
||||
273
src/locales/en.json
Normal file
273
src/locales/en.json
Normal file
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"search": "Search",
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"play": "Play",
|
||||
"logout": "Logout",
|
||||
"login": "Login"
|
||||
},
|
||||
"library": {
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"time_desc": "Time (Newest First)",
|
||||
"time_asc": "Time (Oldest First)",
|
||||
"size_desc": "Size (Largest First)",
|
||||
"size_asc": "Size (Smallest First)",
|
||||
"name_asc": "Name (A to Z)",
|
||||
"name_desc": "Name (Z to A)"
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filter By",
|
||||
"unregistered": "Unregistered",
|
||||
"registered": "Registered",
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"videos_only": "Videos Only",
|
||||
"photos_only": "Photos Only"
|
||||
},
|
||||
"size": {
|
||||
"title": "Size",
|
||||
"min_mb": "Min MB",
|
||||
"max_mb": "Max MB"
|
||||
},
|
||||
"duration": {
|
||||
"title": "Video Duration",
|
||||
"min_minutes": "Min Minutes",
|
||||
"max_minutes": "Max Minutes"
|
||||
},
|
||||
"status": {
|
||||
"files": "{count} files",
|
||||
"registered": "Registered",
|
||||
"unregistered": "Unregistered",
|
||||
"pending": "Pending",
|
||||
"processing": "Processing",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"thumbnail": {
|
||||
"register_to_preview": "Register to see preview"
|
||||
},
|
||||
"context": {
|
||||
"register_file": "Register File",
|
||||
"process_file": "Process File",
|
||||
"reprocess": "Reprocess",
|
||||
"run_identity_agent": "Run Identity Agent",
|
||||
"unregister": "Unregister",
|
||||
"play_video": "Play Video",
|
||||
"processor_selection": "Processor Selection",
|
||||
"overall_progress": "Overall Progress",
|
||||
"missing_file_path": "Missing file path",
|
||||
"file_not_registered": "File not registered",
|
||||
"select_processor": "Please select at least one processor",
|
||||
"file_must_be_completed": "File must be completed first"
|
||||
},
|
||||
"pipeline": {
|
||||
"processors": "Processors",
|
||||
"rule1_ingestion": "Rule1 Ingestion",
|
||||
"face_tracing": "Face Tracing",
|
||||
"tkg_nodes": "TKG Nodes",
|
||||
"tkg_edges": "TKG Edges",
|
||||
"rule2_ingestion": "Rule2 Ingestion"
|
||||
},
|
||||
"phase": {
|
||||
"processor_output": "Rule1: Processor Output",
|
||||
"postgresql_ingestion": "Rule1: PostgreSQL Ingestion",
|
||||
"qdrant_ingestion": "Rule1: Qdrant Ingestion"
|
||||
},
|
||||
"action": {
|
||||
"registering": "Registering...",
|
||||
"registered": "Registered: {name}",
|
||||
"registration_failed": "Registration failed: {message}",
|
||||
"registration_error": "Registration error: {error}",
|
||||
"processing": "Processing...",
|
||||
"processing_started": "Processing started: {name}",
|
||||
"processing_failed": "Processing failed: {message}",
|
||||
"processing_error": "Processing error: {error}",
|
||||
"identity_agent_started": "Identity Agent started: {name}",
|
||||
"identity_agent_failed": "Identity Agent failed: {message}",
|
||||
"identity_agent_error": "Identity Agent error: {error}",
|
||||
"removing": "Removing...",
|
||||
"removed": "Removed: {name}",
|
||||
"removal_failed": "Removal failed: {message}",
|
||||
"removal_error": "Removal error: {error}",
|
||||
"clear_filters": "Clear Filters"
|
||||
},
|
||||
"refresh": "Refresh",
|
||||
"refresh_status": "Refresh Status"
|
||||
},
|
||||
"people": {
|
||||
"toolbar": {
|
||||
"all_files": "All Files",
|
||||
"view_all_people": "View All People",
|
||||
"view_starred_people": "View Starred People",
|
||||
"refresh": "Refresh",
|
||||
"pending_people": "People",
|
||||
"skipped_people": "Skipped People",
|
||||
"pending_faces": "Faces",
|
||||
"face_group": "Face Group"
|
||||
},
|
||||
"section": {
|
||||
"known_people": "Known People",
|
||||
"pending_people": "People",
|
||||
"skipped_people": "Skipped People",
|
||||
"pending_faces": "Faces",
|
||||
"face_group": "Face Group"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"recent": "Recently Viewed",
|
||||
"file_name": "File Name",
|
||||
"name_az": "Name (A to Z)",
|
||||
"name_za": "Name (Z to A)",
|
||||
"time_desc": "Time (Newest First)",
|
||||
"time_asc": "Time (Oldest First)"
|
||||
},
|
||||
"search": {
|
||||
"known_people": "Search known people",
|
||||
"pending_people": "Search pending people",
|
||||
"person_name": "Search person name..."
|
||||
},
|
||||
"action_history": {
|
||||
"title": "Action History · {name}",
|
||||
"undo_all": "Undo All",
|
||||
"redo_all": "Redo All",
|
||||
"delete_person": "Delete This Person",
|
||||
"no_history": "No action history"
|
||||
},
|
||||
"expand": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"view_faces": "View Faces",
|
||||
"appears_in": "Appears in:",
|
||||
"faces": "{count} faces",
|
||||
"set_as_profile": "Set as Profile",
|
||||
"no_face_data": "No face data"
|
||||
},
|
||||
"context": {
|
||||
"star_person": "★ Mark as Important",
|
||||
"unstar_person": "☆ Remove Important",
|
||||
"details": "📋 Details",
|
||||
"confirm_person": "✓ Confirm Person",
|
||||
"pending": "Pending",
|
||||
"edit_name": "✎ Edit Name",
|
||||
"merge_person": "⇄ Already Have This Person",
|
||||
"skip_person": "✕ Skip This Person",
|
||||
"undo": "↩ Undo",
|
||||
"redo": "↪ Redo",
|
||||
"delete_person": "🗑 Delete This Person"
|
||||
},
|
||||
"face_context": {
|
||||
"details": "📋 Details",
|
||||
"assign_existing": "⇄ Assign to Existing Person",
|
||||
"skip_face": "✕ Skip This Face"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign to Existing Person",
|
||||
"search_placeholder": "Search person name...",
|
||||
"no_results": "No matching people found",
|
||||
"confirm_assign": "Confirm Assignment"
|
||||
},
|
||||
"face_detail": {
|
||||
"title": "Pending Face Details",
|
||||
"file": "File:",
|
||||
"trace_id": "Trace ID:",
|
||||
"frame_number": "Frame Number:",
|
||||
"confidence": "Confidence:",
|
||||
"face_id": "Face ID:",
|
||||
"play_segment": "Play Video Segment",
|
||||
"new_identity": "Create as New Person",
|
||||
"enter_name": "Enter person name...",
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Back",
|
||||
"edit": "Edit",
|
||||
"role": "Role",
|
||||
"description": "Description",
|
||||
"enter_name": "Enter name",
|
||||
"enter_alias": "Enter alias and press Enter",
|
||||
"add_alias": "+ Alias",
|
||||
"collapse": "Collapse",
|
||||
"role_name": "Role name",
|
||||
"custom_description": "Custom description",
|
||||
"saving": "Saving...",
|
||||
"save_changes": "✓ Save Changes",
|
||||
"cancel": "Cancel",
|
||||
"add_same_person": "Add same person",
|
||||
"play": "▶ Play",
|
||||
"unbind": "✕ Unbind",
|
||||
"segments": "{count} segments",
|
||||
"traces": "{count} traces",
|
||||
"all_segments": "All segments",
|
||||
"mode_trace": "Trace",
|
||||
"mode_segment": "Segment",
|
||||
"mode_continuous": "Continuous",
|
||||
"status": "Status:",
|
||||
"confirm": "✓ Confirm",
|
||||
"skip": "Skip",
|
||||
"bind_face": "+ Bind Face",
|
||||
"merge_other": "⇄ Merge to Other Person",
|
||||
"delete_person": "Delete This Person",
|
||||
"bind_face_title": "Bind Face",
|
||||
"merge_title": "⇄ Merge to Other Person",
|
||||
"unbind_trace": "Unbind trace T{trace_id} (face_id: {face_id})?",
|
||||
"no_face_id": "This trace has no face_id, cannot unbind",
|
||||
"unbind_failed": "Unbind failed: {error}",
|
||||
"merge_confirm": "Merge \"{source}\" into \"{target}\"?",
|
||||
"not_this_person": "Not This Person",
|
||||
"delete_confirm": "Delete \"{name}\"?",
|
||||
"delete_confirm_message": "Delete \"{name}\"? This action cannot be undone.",
|
||||
"delete_failed": "Delete failed: {error}",
|
||||
"create_identity_failed": "Failed to create person: API did not return identity_uuid",
|
||||
"create_identity_error": "Failed to create person: {error}",
|
||||
"assign_failed": "Assignment failed: {error}"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"welcome": {
|
||||
"title": "👋 Welcome to Momentry Studio",
|
||||
"intro": "I'll help you make better use of the system. Let me introduce the main features:",
|
||||
"smart_search": {
|
||||
"title": "Smart Search",
|
||||
"desc": "Keyword, semantic, and people search modes"
|
||||
},
|
||||
"people_management": {
|
||||
"title": "People Management",
|
||||
"desc": "Identify people in videos, build people database"
|
||||
},
|
||||
"file_management": {
|
||||
"title": "File Management",
|
||||
"desc": "Manage and process your video and photo files"
|
||||
},
|
||||
"got_it": "Got it, start using"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Momentry Studio",
|
||||
"subtitle": "Login to continue",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_placeholder": "Enter password",
|
||||
"login_button": "Login",
|
||||
"logging_in": "Logging in...",
|
||||
"login_success": "Login successful!",
|
||||
"default_credentials": "Default credentials: demo / demo123"
|
||||
},
|
||||
"nav": {
|
||||
"search": "Search",
|
||||
"library": "Library",
|
||||
"people": "People",
|
||||
"admin": "Admin",
|
||||
"client": "Client"
|
||||
}
|
||||
}
|
||||
77
src/locales/index.ts
Normal file
77
src/locales/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './en.json'
|
||||
import zhTW from './zh-TW.json'
|
||||
|
||||
export type MessageSchema = typeof en
|
||||
|
||||
const supportedLanguages = {
|
||||
'en': en,
|
||||
'zh-TW': zhTW,
|
||||
}
|
||||
|
||||
function detectInitialLanguage(): string {
|
||||
const saved = localStorage.getItem('app_language')
|
||||
if (saved && saved in supportedLanguages) {
|
||||
return saved
|
||||
}
|
||||
|
||||
if (saved === 'auto') {
|
||||
return detectOSLanguage()
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
function detectOSLanguage(): string {
|
||||
let osLang = 'en'
|
||||
|
||||
try {
|
||||
if ((window as any).__TAURI__) {
|
||||
const locale = (window as any).__TAURI__?.os?.getLocale?.()
|
||||
if (locale) osLang = locale
|
||||
} else {
|
||||
osLang = navigator.language || 'en'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Language detection failed:', e)
|
||||
}
|
||||
|
||||
if (osLang.startsWith('zh-TW') || osLang.startsWith('zh-Hant')) {
|
||||
return 'zh-TW'
|
||||
} else if (osLang.startsWith('zh-CN') || osLang.startsWith('zh-Hans')) {
|
||||
return 'zh-CN'
|
||||
} else if (osLang.startsWith('ja')) {
|
||||
return 'ja'
|
||||
} else if (osLang.startsWith('ko')) {
|
||||
return 'ko'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW'>({
|
||||
legacy: false,
|
||||
locale: detectInitialLanguage(),
|
||||
fallbackLocale: 'en',
|
||||
messages: supportedLanguages as any,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
})
|
||||
|
||||
export function changeLanguage(lang: string) {
|
||||
if (lang === 'auto') {
|
||||
localStorage.setItem('app_language', 'auto')
|
||||
const detected = detectOSLanguage()
|
||||
i18n.global.locale = detected as any
|
||||
} else {
|
||||
localStorage.setItem('app_language', lang)
|
||||
i18n.global.locale = lang as any
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentLanguage(): string {
|
||||
return i18n.global.locale as string
|
||||
}
|
||||
|
||||
export { detectOSLanguage }
|
||||
export default i18n
|
||||
273
src/locales/zh-TW.json
Normal file
273
src/locales/zh-TW.json
Normal file
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "載入中...",
|
||||
"error": "錯誤",
|
||||
"success": "成功",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"delete": "刪除",
|
||||
"save": "儲存",
|
||||
"edit": "編輯",
|
||||
"search": "搜尋",
|
||||
"back": "返回",
|
||||
"close": "關閉",
|
||||
"play": "播放",
|
||||
"logout": "登出",
|
||||
"login": "登入"
|
||||
},
|
||||
"library": {
|
||||
"sort": {
|
||||
"title": "排序方式",
|
||||
"time_desc": "依時間・由近到遠",
|
||||
"time_asc": "依時間・由遠到近",
|
||||
"size_desc": "依大小・由大到小",
|
||||
"size_asc": "依大小・由小到大",
|
||||
"name_asc": "依檔名・由 A 到 Z",
|
||||
"name_desc": "依檔名・由 Z 到 A"
|
||||
},
|
||||
"filter": {
|
||||
"title": "過濾方式",
|
||||
"unregistered": "未註冊",
|
||||
"registered": "已註冊",
|
||||
"pending": "待處理",
|
||||
"completed": "已完成",
|
||||
"videos_only": "僅顯示影片",
|
||||
"photos_only": "僅顯示照片"
|
||||
},
|
||||
"size": {
|
||||
"title": "大小",
|
||||
"min_mb": "最小 MB",
|
||||
"max_mb": "最大 MB"
|
||||
},
|
||||
"duration": {
|
||||
"title": "影片時長",
|
||||
"min_minutes": "最小分鐘",
|
||||
"max_minutes": "最大分鐘"
|
||||
},
|
||||
"status": {
|
||||
"files": "{count} 個檔案",
|
||||
"registered": "已註冊",
|
||||
"unregistered": "未註冊",
|
||||
"pending": "等待中",
|
||||
"processing": "處理中",
|
||||
"completed": "已完成"
|
||||
},
|
||||
"thumbnail": {
|
||||
"register_to_preview": "註冊以預覽"
|
||||
},
|
||||
"context": {
|
||||
"register_file": "註冊此檔案",
|
||||
"process_file": "處理此檔案",
|
||||
"reprocess": "重新處理",
|
||||
"run_identity_agent": "執行 Identity Agent",
|
||||
"unregister": "移除註冊",
|
||||
"play_video": "播放影片",
|
||||
"processor_selection": "處理器選擇",
|
||||
"overall_progress": "整體進度",
|
||||
"missing_file_path": "缺少檔案路徑",
|
||||
"file_not_registered": "檔案尚未註冊",
|
||||
"select_processor": "請至少選擇一個處理器",
|
||||
"file_must_be_completed": "檔案需先完成處理"
|
||||
},
|
||||
"pipeline": {
|
||||
"processors": "處理器",
|
||||
"rule1_ingestion": "Rule1 入庫",
|
||||
"face_tracing": "Face Tracing",
|
||||
"tkg_nodes": "TKG Nodes",
|
||||
"tkg_edges": "TKG Edges",
|
||||
"rule2_ingestion": "Rule2 入庫"
|
||||
},
|
||||
"phase": {
|
||||
"processor_output": "Rule1: 處理器輸出",
|
||||
"postgresql_ingestion": "Rule1: PostgreSQL 入庫",
|
||||
"qdrant_ingestion": "Rule1: Qdrant 入庫"
|
||||
},
|
||||
"action": {
|
||||
"registering": "註冊中...",
|
||||
"registered": "註冊成功:{name}",
|
||||
"registration_failed": "註冊失敗:{message}",
|
||||
"registration_error": "註冊錯誤:{error}",
|
||||
"processing": "處理中...",
|
||||
"processing_started": "處理已觸發:{name}",
|
||||
"processing_failed": "處理失敗:{message}",
|
||||
"processing_error": "處理錯誤:{error}",
|
||||
"identity_agent_started": "Identity Agent 已啟動:{name}",
|
||||
"identity_agent_failed": "Identity Agent 失敗:{message}",
|
||||
"identity_agent_error": "Identity Agent 錯誤:{error}",
|
||||
"removing": "移除中...",
|
||||
"removed": "移除成功:{name}",
|
||||
"removal_failed": "移除失敗:{message}",
|
||||
"removal_error": "移除錯誤:{error}",
|
||||
"clear_filters": "清除篩選"
|
||||
},
|
||||
"refresh": "重新整理",
|
||||
"refresh_status": "重新整理狀態"
|
||||
},
|
||||
"people": {
|
||||
"toolbar": {
|
||||
"all_files": "全部檔案",
|
||||
"view_all_people": "查看所有人物",
|
||||
"view_starred_people": "查看重要人物",
|
||||
"refresh": "重新整理",
|
||||
"pending_people": "人物",
|
||||
"skipped_people": "已略過",
|
||||
"pending_faces": "人臉",
|
||||
"face_group": "人臉群組"
|
||||
},
|
||||
"section": {
|
||||
"known_people": "已知人物",
|
||||
"pending_people": "人物",
|
||||
"skipped_people": "已略過",
|
||||
"pending_faces": "人臉",
|
||||
"face_group": "人臉群組"
|
||||
},
|
||||
"sort": {
|
||||
"title": "排序方式",
|
||||
"recent": "最近瀏覽",
|
||||
"file_name": "依檔案名稱",
|
||||
"name_az": "依檔名・由 A 到 Z",
|
||||
"name_za": "依檔名・由 Z 到 A",
|
||||
"time_desc": "依時間・由近到遠",
|
||||
"time_asc": "依時間・由遠到近"
|
||||
},
|
||||
"search": {
|
||||
"known_people": "搜尋已知人物",
|
||||
"pending_people": "搜尋待定人物",
|
||||
"person_name": "搜尋人物名稱..."
|
||||
},
|
||||
"action_history": {
|
||||
"title": "操作記錄 · {name}",
|
||||
"undo_all": "全部復原",
|
||||
"redo_all": "全部重做",
|
||||
"delete_person": "刪除此人物",
|
||||
"no_history": "尚無操作記錄"
|
||||
},
|
||||
"expand": {
|
||||
"collapse": "收起",
|
||||
"expand": "展開",
|
||||
"view_faces": "查看面孔",
|
||||
"appears_in": "出現檔案:",
|
||||
"faces": "{count} 面孔",
|
||||
"set_as_profile": "設為大頭貼",
|
||||
"no_face_data": "無面孔資料"
|
||||
},
|
||||
"context": {
|
||||
"star_person": "★ 標為重要人物",
|
||||
"unstar_person": "☆ 取消重要人物",
|
||||
"details": "📋 詳情",
|
||||
"confirm_person": "✓ 確認人物",
|
||||
"pending": "待定",
|
||||
"edit_name": "✎ 編輯名稱",
|
||||
"merge_person": "⇄ 已有此人物",
|
||||
"skip_person": "✕ 略過此人物",
|
||||
"undo": "↩ 復原",
|
||||
"redo": "↪ 重做",
|
||||
"delete_person": "🗑 刪除此人物"
|
||||
},
|
||||
"face_context": {
|
||||
"details": "📋 詳情",
|
||||
"assign_existing": "⇄ 指派給現有人物",
|
||||
"skip_face": "✕ 略過此人臉"
|
||||
},
|
||||
"assign": {
|
||||
"title": "指派給現有人物",
|
||||
"search_placeholder": "搜尋人物名稱...",
|
||||
"no_results": "沒有找到符合的人物",
|
||||
"confirm_assign": "確認指派"
|
||||
},
|
||||
"face_detail": {
|
||||
"title": "待定人臉詳情",
|
||||
"file": "檔案:",
|
||||
"trace_id": "Trace ID:",
|
||||
"frame_number": "帧號:",
|
||||
"confidence": "置信度:",
|
||||
"face_id": "Face ID:",
|
||||
"play_segment": "播放影片片段",
|
||||
"new_identity": "新增為新人物",
|
||||
"enter_name": "輸入人物名稱...",
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
},
|
||||
"detail": {
|
||||
"back": "返回",
|
||||
"edit": "編輯",
|
||||
"role": "角色",
|
||||
"description": "描述",
|
||||
"enter_name": "加入人名",
|
||||
"enter_alias": "輸入別名後按 Enter",
|
||||
"add_alias": "+ 別名",
|
||||
"collapse": "收起",
|
||||
"role_name": "角色名稱",
|
||||
"custom_description": "自訂描述",
|
||||
"saving": "儲存中...",
|
||||
"save_changes": "✓ 儲存更改",
|
||||
"cancel": "取消",
|
||||
"add_same_person": "加入相同人物",
|
||||
"play": "▶ 播放",
|
||||
"unbind": "✕ 解綁",
|
||||
"segments": "{count} 個片段",
|
||||
"traces": "{count} 個追蹤",
|
||||
"all_segments": "所有片段",
|
||||
"mode_trace": "追蹤",
|
||||
"mode_segment": "片段",
|
||||
"mode_continuous": "連續",
|
||||
"status": "狀態:",
|
||||
"confirm": "✓ 確認",
|
||||
"skip": "略過",
|
||||
"bind_face": "+ 綁定人臉",
|
||||
"merge_other": "⇄ 合併到其他人物",
|
||||
"delete_person": "刪除此人物",
|
||||
"bind_face_title": "Bind Face",
|
||||
"merge_title": "⇄ 合併到其他人物",
|
||||
"unbind_trace": "解綁 trace T{trace_id}(face_id: {face_id})?",
|
||||
"no_face_id": "此 trace 沒有 face_id,無法解綁",
|
||||
"unbind_failed": "解綁失敗:{error}",
|
||||
"merge_confirm": "合併「{source}」到「{target}」?",
|
||||
"not_this_person": "不是此人物",
|
||||
"delete_confirm": "刪除「{name}」?",
|
||||
"delete_confirm_message": "刪除「{name}」?此操作無法復原。",
|
||||
"delete_failed": "刪除失敗:{error}",
|
||||
"create_identity_failed": "新增人物失敗:API 未返回 identity_uuid",
|
||||
"create_identity_error": "新增人物失敗:{error}",
|
||||
"assign_failed": "指派失敗:{error}"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"welcome": {
|
||||
"title": "👋 歡迎使用 Momentry Studio",
|
||||
"intro": "我會幫助你更好地使用系統。讓我介紹一下主要功能:",
|
||||
"smart_search": {
|
||||
"title": "智能搜索",
|
||||
"desc": "關鍵詞、語義、人物三種搜索模式"
|
||||
},
|
||||
"people_management": {
|
||||
"title": "人物管理",
|
||||
"desc": "識別視頻中的人物,建立人物庫"
|
||||
},
|
||||
"file_management": {
|
||||
"title": "文件管理",
|
||||
"desc": "管理和處理你的視頻、照片文件"
|
||||
},
|
||||
"got_it": "知道了,開始使用"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "Momentry Studio",
|
||||
"subtitle": "Login to continue",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_placeholder": "Enter password",
|
||||
"login_button": "Login",
|
||||
"logging_in": "Logging in...",
|
||||
"login_success": "Login successful!",
|
||||
"default_credentials": "Default credentials: demo / demo123"
|
||||
},
|
||||
"nav": {
|
||||
"search": "Search",
|
||||
"library": "Library",
|
||||
"people": "People",
|
||||
"admin": "Admin",
|
||||
"client": "Client"
|
||||
}
|
||||
}
|
||||
19
src/main.ts.backup_local
Normal file
19
src/main.ts.backup_local
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import ClientView from './views/ClientView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/login', name: 'Login', component: LoginView },
|
||||
{ path: '/client', name: 'Client', component: ClientView },
|
||||
{ path: '/search', name: 'Search', component: { template: '<div>Search Page (Coming Soon)</div>' } },
|
||||
],
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
28
src/stores/advancedMode.ts
Normal file
28
src/stores/advancedMode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Advanced Mode Store - controls visibility of advanced features
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const ADVANCED_KEY = 'momentry_advanced_mode'
|
||||
|
||||
// Load initial state
|
||||
const savedAdvanced = localStorage.getItem(ADVANCED_KEY)
|
||||
const isAdvancedMode = ref(savedAdvanced === 'true')
|
||||
|
||||
export function useAdvancedMode() {
|
||||
const isAdvanced = computed(() => isAdvancedMode.value)
|
||||
|
||||
function toggleAdvanced() {
|
||||
isAdvancedMode.value = !isAdvancedMode.value
|
||||
localStorage.setItem(ADVANCED_KEY, isAdvancedMode.value.toString())
|
||||
}
|
||||
|
||||
function setAdvanced(value: boolean) {
|
||||
isAdvancedMode.value = value
|
||||
localStorage.setItem(ADVANCED_KEY, value.toString())
|
||||
}
|
||||
|
||||
return {
|
||||
isAdvanced,
|
||||
toggleAdvanced,
|
||||
setAdvanced
|
||||
}
|
||||
}
|
||||
48
src/stores/agentStore.ts
Normal file
48
src/stores/agentStore.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const agentTip = ref<string>('')
|
||||
export const showAgentTip = ref(false)
|
||||
export const agentSuggestions = ref<string[]>([])
|
||||
export const discoveryTip = ref<string>('')
|
||||
export const userBehavior = ref({
|
||||
hasUsedKeywordSearch: false,
|
||||
hasUsedSemanticSearch: false,
|
||||
hasUsedPeopleSearch: false,
|
||||
hasUsedFileFilter: false,
|
||||
hasUsedContextMenu: false,
|
||||
hasViewedPersonDetails: false,
|
||||
hasBindedFace: false,
|
||||
hasUsedSearchHistory: false,
|
||||
hasUsedBookmark: false
|
||||
})
|
||||
|
||||
export function showTip(message: string, duration = 5000) {
|
||||
agentTip.value = message
|
||||
showAgentTip.value = true
|
||||
setTimeout(() => {
|
||||
showAgentTip.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
export function showDiscovery(message: string, duration = 8000) {
|
||||
discoveryTip.value = message
|
||||
setTimeout(() => {
|
||||
discoveryTip.value = ''
|
||||
}, duration)
|
||||
}
|
||||
|
||||
export function trackBehavior(action: keyof typeof userBehavior.value) {
|
||||
userBehavior.value[action] = true
|
||||
localStorage.setItem('momentry_user_behavior', JSON.stringify(userBehavior.value))
|
||||
}
|
||||
|
||||
export function loadUserBehavior() {
|
||||
const saved = localStorage.getItem('momentry_user_behavior')
|
||||
if (saved) {
|
||||
try {
|
||||
userBehavior.value = JSON.parse(saved)
|
||||
} catch (e) {
|
||||
console.error('Failed to load user behavior:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/utils/mockPoseData.ts
Normal file
60
src/utils/mockPoseData.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||
|
||||
export const mockPose: PoseResponse = {
|
||||
frame: 100,
|
||||
keypoints: [
|
||||
{ name: 'nose', x: 100, y: 50, confidence: 0.9 },
|
||||
{ name: 'left_eye', x: 90, y: 45, confidence: 0.95 },
|
||||
{ name: 'right_eye', x: 110, y: 45, confidence: 0.95 },
|
||||
{ name: 'left_ear', x: 80, y: 50, confidence: 0.85 },
|
||||
{ name: 'right_ear', x: 120, y: 50, confidence: 0.85 },
|
||||
{ name: 'left_shoulder', x: 60, y: 100, confidence: 0.9 },
|
||||
{ name: 'right_shoulder', x: 140, y: 100, confidence: 0.9 },
|
||||
{ name: 'left_elbow', x: 50, y: 150, confidence: 0.85 },
|
||||
{ name: 'right_elbow', x: 150, y: 150, confidence: 0.85 },
|
||||
{ name: 'left_wrist', x: 45, y: 190, confidence: 0.8 },
|
||||
{ name: 'right_wrist', x: 155, y: 190, confidence: 0.8 },
|
||||
{ name: 'left_hip', x: 70, y: 200, confidence: 0.9 },
|
||||
{ name: 'right_hip', x: 130, y: 200, confidence: 0.9 },
|
||||
{ name: 'left_knee', x: 65, y: 280, confidence: 0.85 },
|
||||
{ name: 'right_knee', x: 135, y: 280, confidence: 0.85 },
|
||||
{ name: 'left_ankle', x: 60, y: 350, confidence: 0.8 },
|
||||
{ name: 'right_ankle', x: 140, y: 350, confidence: 0.8 },
|
||||
],
|
||||
pose_class: 'standing',
|
||||
confidence: 0.92
|
||||
}
|
||||
|
||||
export const mockAppearance: AppearanceResponse = {
|
||||
frame: 100,
|
||||
dominant_colors: [
|
||||
{ rgb: [255, 100, 50], percentage: 0.35 },
|
||||
{ rgb: [50, 150, 200], percentage: 0.25 },
|
||||
{ rgb: [100, 200, 100], percentage: 0.15 },
|
||||
]
|
||||
}
|
||||
|
||||
export const mockSittingPose: PoseResponse = {
|
||||
frame: 200,
|
||||
keypoints: [
|
||||
{ name: 'nose', x: 100, y: 60, confidence: 0.88 },
|
||||
{ name: 'left_eye', x: 90, y: 55, confidence: 0.92 },
|
||||
{ name: 'right_eye', x: 110, y: 55, confidence: 0.92 },
|
||||
{ name: 'left_ear', x: 80, y: 60, confidence: 0.82 },
|
||||
{ name: 'right_ear', x: 120, y: 60, confidence: 0.82 },
|
||||
{ name: 'left_shoulder', x: 60, y: 110, confidence: 0.88 },
|
||||
{ name: 'right_shoulder', x: 140, y: 110, confidence: 0.88 },
|
||||
{ name: 'left_elbow', x: 55, y: 140, confidence: 0.83 },
|
||||
{ name: 'right_elbow', x: 145, y: 140, confidence: 0.83 },
|
||||
{ name: 'left_wrist', x: 50, y: 160, confidence: 0.78 },
|
||||
{ name: 'right_wrist', x: 150, y: 160, confidence: 0.78 },
|
||||
{ name: 'left_hip', x: 70, y: 180, confidence: 0.88 },
|
||||
{ name: 'right_hip', x: 130, y: 180, confidence: 0.88 },
|
||||
{ name: 'left_knee', x: 90, y: 200, confidence: 0.83 },
|
||||
{ name: 'right_knee', x: 110, y: 200, confidence: 0.83 },
|
||||
{ name: 'left_ankle', x: 85, y: 240, confidence: 0.78 },
|
||||
{ name: 'right_ankle', x: 115, y: 240, confidence: 0.78 },
|
||||
],
|
||||
pose_class: 'sitting',
|
||||
confidence: 0.87
|
||||
}
|
||||
264
src/utils/poseRenderer.ts
Normal file
264
src/utils/poseRenderer.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||
|
||||
const SKELETON_CONNECTIONS: [string, string][] = [
|
||||
['nose', 'left_eye'],
|
||||
['nose', 'right_eye'],
|
||||
['left_eye', 'left_ear'],
|
||||
['right_eye', 'right_ear'],
|
||||
['left_shoulder', 'right_shoulder'],
|
||||
['left_shoulder', 'left_elbow'],
|
||||
['left_elbow', 'left_wrist'],
|
||||
['right_shoulder', 'right_elbow'],
|
||||
['right_elbow', 'right_wrist'],
|
||||
['left_shoulder', 'left_hip'],
|
||||
['right_shoulder', 'right_hip'],
|
||||
['left_hip', 'right_hip'],
|
||||
['left_hip', 'left_knee'],
|
||||
['left_knee', 'left_ankle'],
|
||||
['right_hip', 'right_knee'],
|
||||
['right_knee', 'right_ankle'],
|
||||
]
|
||||
|
||||
function transformKeypoints(
|
||||
keypoints: Array<{ name: string; x: number; y: number; confidence?: number }>,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
): Map<string, { x: number; y: number; confidence: number }> {
|
||||
console.log('[transformKeypoints] input keypoints:', keypoints?.length)
|
||||
|
||||
const validKeypoints = keypoints.filter(kp => {
|
||||
const conf = kp.confidence ?? 0
|
||||
return conf > 0 && kp.x > 0 && kp.y > 0
|
||||
})
|
||||
|
||||
console.log('[transformKeypoints] valid keypoints:', validKeypoints.length)
|
||||
|
||||
if (validKeypoints.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
let minX = Infinity, maxX = -Infinity
|
||||
let minY = Infinity, maxY = -Infinity
|
||||
for (const kp of validKeypoints) {
|
||||
minX = Math.min(minX, kp.x)
|
||||
maxX = Math.max(maxX, kp.x)
|
||||
minY = Math.min(minY, kp.y)
|
||||
maxY = Math.max(maxY, kp.y)
|
||||
}
|
||||
|
||||
const width = maxX - minX || 1
|
||||
const height = maxY - minY || 1
|
||||
|
||||
console.log('[transformKeypoints] bounding box:', minX, minY, 'to', maxX, maxY)
|
||||
console.log('[transformKeypoints] bbox size:', width, 'x', height)
|
||||
|
||||
const padding = 20
|
||||
const availableWidth = canvasWidth - 2 * padding
|
||||
const availableHeight = canvasHeight - 2 * padding
|
||||
|
||||
const scaleX = availableWidth / width
|
||||
const scaleY = availableHeight / height
|
||||
const scale = Math.min(scaleX, scaleY)
|
||||
|
||||
const scaledWidth = width * scale
|
||||
const scaledHeight = height * scale
|
||||
const offsetX = (canvasWidth - scaledWidth) / 2 - minX * scale
|
||||
const offsetY = (canvasHeight - scaledHeight) / 2 - minY * scale
|
||||
|
||||
console.log('[transformKeypoints] scale:', scale, 'offset:', offsetX, offsetY)
|
||||
|
||||
const keypointMap = new Map<string, { x: number; y: number; confidence: number }>()
|
||||
for (const kp of keypoints) {
|
||||
const conf = kp.confidence ?? 0
|
||||
keypointMap.set(kp.name, {
|
||||
x: kp.x * scale + offsetX,
|
||||
y: kp.y * scale + offsetY,
|
||||
confidence: conf
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[transformKeypoints] First 3 transformed:',
|
||||
[...keypointMap.entries()].slice(0, 3).map(([k, v]) => ({ name: k, ...v })))
|
||||
|
||||
return keypointMap
|
||||
}
|
||||
|
||||
export function drawPoseSkeleton(
|
||||
canvas: HTMLCanvasElement,
|
||||
pose: PoseResponse,
|
||||
options?: {
|
||||
lineWidth?: number
|
||||
pointRadius?: number
|
||||
lineColor?: string
|
||||
pointColor?: string
|
||||
videoWidth?: number
|
||||
videoHeight?: number
|
||||
noTransform?: boolean
|
||||
}
|
||||
) {
|
||||
console.log('[drawPoseSkeleton] canvas size:', canvas.width, 'x', canvas.height)
|
||||
console.log('[drawPoseSkeleton] pose:', pose)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
console.error('[drawPoseSkeleton] No context')
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
lineWidth = 2,
|
||||
pointRadius = 4,
|
||||
lineColor = '#00ff00',
|
||||
pointColor = '#ff0000',
|
||||
noTransform = false
|
||||
} = options || {}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 不轉換,直接畫原始座標(用於 debug)
|
||||
if (noTransform) {
|
||||
console.log('[drawPoseSkeleton] Drawing raw keypoints (no transform)')
|
||||
|
||||
const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0)
|
||||
|
||||
if (validKeypoints.length === 0) {
|
||||
ctx.fillStyle = '#9aa0a6'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No valid keypoints', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
// 找 bounding box
|
||||
let minX = Infinity, maxX = -Infinity
|
||||
let minY = Infinity, maxY = -Infinity
|
||||
for (const kp of validKeypoints) {
|
||||
minX = Math.min(minX, kp.x)
|
||||
maxX = Math.max(maxX, kp.x)
|
||||
minY = Math.min(minY, kp.y)
|
||||
maxY = Math.max(maxY, kp.y)
|
||||
}
|
||||
|
||||
console.log('[drawPoseSkeleton] Raw bbox:', minX, minY, 'to', maxX, maxY)
|
||||
|
||||
// 繪製
|
||||
ctx.fillStyle = pointColor
|
||||
for (const kp of validKeypoints) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(kp.x, kp.y, pointRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||
|
||||
console.log('[drawPoseSkeleton] transformed keypoints:', keypointMap.size)
|
||||
|
||||
if (keypointMap.size === 0) {
|
||||
console.log('[drawPoseSkeleton] No valid keypoints')
|
||||
ctx.fillStyle = '#9aa0a6'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('No pose detected', canvas.width / 2, canvas.height / 2)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.lineWidth = lineWidth
|
||||
for (const [from, to] of SKELETON_CONNECTIONS) {
|
||||
const fromPt = keypointMap.get(from)
|
||||
const toPt = keypointMap.get(to)
|
||||
if (fromPt && toPt && fromPt.confidence > 0 && toPt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(fromPt.x, fromPt.y)
|
||||
ctx.lineTo(toPt.x, toPt.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = pointColor
|
||||
for (const kp of pose.keypoints) {
|
||||
const transformed = keypointMap.get(kp.name)
|
||||
if (transformed && transformed.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(transformed.x, transformed.y, pointRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawAppearanceOverlay(
|
||||
canvas: HTMLCanvasElement,
|
||||
appearance: AppearanceResponse,
|
||||
pose?: PoseResponse
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (!appearance.dominant_colors?.length) {
|
||||
if (appearance.hsv_histogram && appearance.hsv_histogram.length > 0) {
|
||||
ctx.fillStyle = '#f8f9fa'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = '#5f6368'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('HSV histogram', canvas.width / 2, canvas.height / 2 - 10)
|
||||
ctx.fillText(`(${appearance.hsv_histogram.length} channels)`, canvas.width / 2, canvas.height / 2 + 10)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!pose) {
|
||||
const swatchWidth = canvas.width / appearance.dominant_colors.length
|
||||
appearance.dominant_colors.forEach((color, i) => {
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
ctx.fillRect(i * swatchWidth, 0, swatchWidth, canvas.height)
|
||||
if (color.percentage) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillText(`${Math.round(color.percentage * 100)}%`, i * swatchWidth + 4, canvas.height - 8)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||
|
||||
if (keypointMap.size === 0) return
|
||||
|
||||
ctx.globalAlpha = 0.4
|
||||
|
||||
const upperBodyPts = ['left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow']
|
||||
const lowerBodyPts = ['left_hip', 'right_hip', 'left_knee', 'right_knee']
|
||||
|
||||
if (appearance.dominant_colors[0]) {
|
||||
const color = appearance.dominant_colors[0]
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
for (const name of upperBodyPts) {
|
||||
const pt = keypointMap.get(name)
|
||||
if (pt && pt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appearance.dominant_colors[1]) {
|
||||
const color = appearance.dominant_colors[1]
|
||||
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||
for (const name of lowerBodyPts) {
|
||||
const pt = keypointMap.get(name)
|
||||
if (pt && pt.confidence > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
85
src/views/AdminView.vue.backup_20260626_162155
Normal file
85
src/views/AdminView.vue.backup_20260626_162155
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div id="ms-view-admin">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'" class="users-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.name">
|
||||
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
</style>
|
||||
84
src/views/ClientView.vue.backup_20260626_162155
Normal file
84
src/views/ClientView.vue.backup_20260626_162155
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div id="ms-view-client">
|
||||
<div class="client-header">
|
||||
<h1>File Manager</h1>
|
||||
<div class="client-toolbar">
|
||||
<button @click="goUp">Up</button>
|
||||
<button @click="createFolder">New Folder</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||
<span class="current-path">{{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="client-content">
|
||||
<table class="file-table">
|
||||
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
|
||||
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
|
||||
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = ref<any[]>([])
|
||||
const selectedFile = ref<any>(null)
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const data = await apiCall('list_client_files', { path: currentPath.value })
|
||||
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
|
||||
} catch (e) { console.error(e); files.value = [] }
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parts = currentPath.value.split('/').filter(p => p)
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
function selectFile(f: any) {
|
||||
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
|
||||
else { selectedFile.value = f }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = prompt('Folder name:')
|
||||
if (!name) return
|
||||
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
|
||||
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
|
||||
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
|
||||
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||
}
|
||||
|
||||
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
|
||||
|
||||
onMounted(loadFiles)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-client { padding: 20px; }
|
||||
.client-header { margin-bottom: 15px; }
|
||||
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||||
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
|
||||
.client-toolbar button:disabled { opacity: 0.5; }
|
||||
.current-path { margin-left: 20px; font-weight: bold; }
|
||||
.file-table { width: 100%; border-collapse: collapse; }
|
||||
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
|
||||
.file-table tr.selected { background: #e3f2fd; }
|
||||
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
|
||||
</style>
|
||||
@@ -1353,26 +1353,29 @@ async function playFaceDetailVideo() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
if (!c || !c.file_uuid) return
|
||||
const frame = c.frame_number || 0
|
||||
const defaultFps = 30
|
||||
|
||||
try {
|
||||
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
|
||||
const fps = fileInfo?.fps || 30
|
||||
const fps = fileInfo?.fps || defaultFps
|
||||
const frameTime = frame / fps
|
||||
const startTime = Math.max(0, frameTime - 5)
|
||||
const endTime = frameTime + 5
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startFrame: startTime * fps,
|
||||
endFrame: endTime * fps,
|
||||
startFrame: Math.round(startTime * fps),
|
||||
endFrame: Math.round(endTime * fps),
|
||||
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
|
||||
}
|
||||
playing.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to get file info:', e)
|
||||
const frameTime = frame / defaultFps
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startFrame: Math.round(Math.max(0, frameTime - 5) * 30),
|
||||
endFrame: Math.round((frameTime + 5) * 30),
|
||||
title: `F${Math.round(Math.max(0, frameTime - 5) * 30)}-F${Math.round((frameTime + 5) * 30)}`
|
||||
startFrame: Math.round(Math.max(0, frameTime - 5) * defaultFps),
|
||||
endFrame: Math.round((frameTime + 5) * defaultFps),
|
||||
title: `F${Math.round(Math.max(0, frameTime - 5) * defaultFps)}-F${Math.round((frameTime + 5) * defaultFps)}`
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
247
vlm_search.py
Normal file
247
vlm_search.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VLM-based semantic search for video keyframes.
|
||||
Architecture:
|
||||
1. Extract keyframes from video at configurable interval
|
||||
2. Send each keyframe to VLM (Ollama LLaVA) for description
|
||||
3. Store frame descriptions in SQLite with embedding
|
||||
4. Search via text similarity using embedding model
|
||||
|
||||
Usage:
|
||||
python3 vlm_search.py index <file_uuid> # Index a video
|
||||
python3 vlm_search.py search <query> # Search indexed frames
|
||||
python3 vlm_search.py status # Show index status
|
||||
python3 vlm_search.py benchmark <file_uuid> # Benchmark VLM speed
|
||||
"""
|
||||
import sys, os, json, time, sqlite3, argparse, base64, io, struct
|
||||
from pathlib import Path
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
# ─── Configuration ───
|
||||
CORE_API = "http://localhost:3002"
|
||||
PROXY = "http://localhost:8888"
|
||||
API_KEY = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"
|
||||
DB_PATH = Path(__file__).parent / "data" / "vlm_index.sqlite"
|
||||
FRAME_INTERVAL = 120 # every N frames (default ~5s at 24fps)
|
||||
VLM_MODEL = "llava"
|
||||
EMBED_MODEL = "mxbai-embed-large"
|
||||
|
||||
def api_get(path):
|
||||
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||
with urllib.request.urlopen(url) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def api_post(path, body):
|
||||
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def get_thumbnail_proxy(file_uuid, frame):
|
||||
url = f"{PROXY}/api/v1/file/{file_uuid}/thumbnail?frame={frame}"
|
||||
with urllib.request.urlopen(url) as r:
|
||||
return r.read()
|
||||
|
||||
# ─── SQLite Index ───
|
||||
def init_db():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_uuid TEXT NOT NULL,
|
||||
frame INTEGER NOT NULL,
|
||||
time_sec REAL,
|
||||
description TEXT,
|
||||
embedding BLOB,
|
||||
created_at REAL DEFAULT (julianday('now'))
|
||||
)""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_desc ON frames(description)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_file ON frames(file_uuid)")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
def get_embedding(text):
|
||||
"""Use Ollama embedding model to get vector."""
|
||||
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:11434/api/embeddings",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
return data.get("embedding")
|
||||
except Exception as e:
|
||||
print(f" Embedding error: {e}")
|
||||
return None
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
dot = sum(x*y for x,y in zip(a,b))
|
||||
na = sum(x*x for x in a)**0.5
|
||||
nb = sum(x*x for x in b)**0.5
|
||||
return dot / (na * nb) if na and nb else 0
|
||||
|
||||
# ─── VLM Inference ───
|
||||
def vlm_describe(image_bytes, prompt="Describe this image in 1-2 sentences. Focus on what is happening, people, objects, and scene."):
|
||||
b64 = base64.b64encode(image_bytes).decode()
|
||||
payload = json.dumps({
|
||||
"model": VLM_MODEL,
|
||||
"prompt": prompt,
|
||||
"images": [b64],
|
||||
"stream": False,
|
||||
"options": {"num_predict": 128}
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:11434/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
data = json.loads(r.read())
|
||||
return data.get("response", "").strip()
|
||||
|
||||
# ─── Commands ───
|
||||
def cmd_index(file_uuid, interval=FRAME_INTERVAL, limit=None):
|
||||
conn = init_db()
|
||||
info = api_get(f"/api/v1/file/{file_uuid}")
|
||||
if not info.get("file_uuid"):
|
||||
print(f"File not found: {file_uuid}")
|
||||
return
|
||||
file_name = info.get("file_name", "?")
|
||||
fps = info.get("fps", 24)
|
||||
duration = info.get("duration", 0)
|
||||
total = 0
|
||||
frame = 0
|
||||
times = []
|
||||
print(f"Indexing: {file_name} ({duration:.1f}s @ {fps:.2f}fps)")
|
||||
while True:
|
||||
if limit and total >= limit:
|
||||
break
|
||||
try:
|
||||
img = get_thumbnail_proxy(file_uuid, frame)
|
||||
except Exception as e:
|
||||
if "404" in str(e):
|
||||
break # no more frames
|
||||
print(f" Error frame {frame}: {e}")
|
||||
frame += interval
|
||||
continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
desc = vlm_describe(img, "Describe this image briefly. What are people doing? What are they wearing? What objects/scene?")
|
||||
except Exception as e:
|
||||
print(f" VLM error frame {frame}: {e}")
|
||||
frame += interval
|
||||
continue
|
||||
elapsed = time.time() - t0
|
||||
times.append(elapsed)
|
||||
# Get embedding
|
||||
emb = get_embedding(desc)
|
||||
emb_blob = struct.pack(f"{len(emb)}d", *emb) if emb else None
|
||||
conn.execute(
|
||||
"INSERT INTO frames (file_uuid, frame, time_sec, description, embedding) VALUES (?,?,?,?,?)",
|
||||
(file_uuid, frame, frame/fps, desc, emb_blob)
|
||||
)
|
||||
conn.commit()
|
||||
total += 1
|
||||
print(f" F{frame} ({frame/fps:.1f}s) [{elapsed:.1f}s] {desc[:80]}")
|
||||
frame += interval
|
||||
avg = sum(times)/len(times) if times else 0
|
||||
print(f"\nDone: {total} frames indexed, avg {avg:.2f}s per VLM call")
|
||||
|
||||
def cmd_status():
|
||||
conn = init_db()
|
||||
rows = conn.execute("""
|
||||
SELECT file_uuid, COUNT(*), MIN(frame), MAX(frame), MIN(created_at)
|
||||
FROM frames GROUP BY file_uuid
|
||||
""").fetchall()
|
||||
total = conn.execute("SELECT COUNT(*) FROM frames").fetchone()[0]
|
||||
print(f"Total indexed frames: {total}")
|
||||
for r in rows:
|
||||
print(f" {r[0][:12]}: {r[1]} frames (F{r[2]}–F{r[3]})")
|
||||
|
||||
def cmd_search(query, top_k=10):
|
||||
conn = init_db()
|
||||
# Get query embedding
|
||||
q_emb = get_embedding(query)
|
||||
if not q_emb:
|
||||
# Fallback to keyword search
|
||||
print("Embedding failed, using keyword search fallback")
|
||||
rows = conn.execute(
|
||||
"SELECT file_uuid, frame, time_sec, description FROM frames WHERE description LIKE ? LIMIT ?",
|
||||
(f"%{query}%", top_k)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
print("No results")
|
||||
return
|
||||
for r in rows:
|
||||
print(f" [{r[0][:12]}] F{r[1]} ({r[2]:.1f}s): {r[3][:100]}")
|
||||
return
|
||||
# Cosine similarity search
|
||||
rows = conn.execute("SELECT id, file_uuid, frame, time_sec, description, embedding FROM frames").fetchall()
|
||||
scored = []
|
||||
for r in rows:
|
||||
emb_blob = r[5]
|
||||
if not emb_blob or len(emb_blob) < 8:
|
||||
continue
|
||||
emb = struct.unpack(f"{len(emb_blob)//8}d", emb_blob)
|
||||
sim = cosine_similarity(q_emb, emb)
|
||||
scored.append((sim, r[1], r[2], r[3], r[4]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
print(f"Top {min(top_k, len(scored))} results for '{query}':")
|
||||
for sim, fu, fr, ts, desc in scored[:top_k]:
|
||||
print(f" [{sim:.3f}] [{fu[:12]}] F{fr} ({ts:.1f}s): {desc[:100]}")
|
||||
|
||||
def cmd_benchmark(file_uuid, count=5):
|
||||
"""Benchmark VLM speed by processing N frames."""
|
||||
times = []
|
||||
for i in range(count):
|
||||
frame = i * 120
|
||||
try:
|
||||
img = get_thumbnail_proxy(file_uuid, frame)
|
||||
except:
|
||||
print(f" Frame {frame}: not available")
|
||||
continue
|
||||
t0 = time.time()
|
||||
desc = vlm_describe(img)
|
||||
elapsed = time.time() - t0
|
||||
times.append(elapsed)
|
||||
print(f" F{frame}: {elapsed:.2f}s — {desc[:60]}")
|
||||
if times:
|
||||
print(f"\nAvg: {sum(times)/len(times):.2f}s, Min: {min(times):.2f}s, Max: {max(times):.2f}s, Total: {sum(times):.1f}s")
|
||||
|
||||
# ─── Main ───
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="VLM Semantic Search for Video Keyframes")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
p_idx = sub.add_parser("index", help="Index a video's keyframes")
|
||||
p_idx.add_argument("file_uuid")
|
||||
p_idx.add_argument("--interval", type=int, default=FRAME_INTERVAL)
|
||||
p_idx.add_argument("--limit", type=int)
|
||||
|
||||
p_search = sub.add_parser("search", help="Search indexed frames")
|
||||
p_search.add_argument("query")
|
||||
p_search.add_argument("--top-k", type=int, default=10)
|
||||
|
||||
sub.add_parser("status", help="Show index status")
|
||||
|
||||
p_bench = sub.add_parser("benchmark", help="Benchmark VLM speed")
|
||||
p_bench.add_argument("file_uuid")
|
||||
p_bench.add_argument("--count", type=int, default=5)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.cmd == "index":
|
||||
cmd_index(args.file_uuid, args.interval, args.limit)
|
||||
elif args.cmd == "search":
|
||||
cmd_search(args.query, args.top_k)
|
||||
elif args.cmd == "status":
|
||||
cmd_status()
|
||||
elif args.cmd == "benchmark":
|
||||
cmd_benchmark(args.file_uuid, args.count)
|
||||
else:
|
||||
parser.print_help()
|
||||
Reference in New Issue
Block a user