fix(bitable): 修复表格头部与数据行之间的幽灵空白
- BitableGrid.vue: 小数据量时移除 height 属性,让 vxe-grid 按内容高度渲染 - 为 .bitable-grid-scope__group-grid 添加 flex:1/min-height:0,避免 vxe-table 回退到 16777216px 的异常高度 - 小数据量时禁用固定列,避免固定列容器错位导致底部出现重复表头 - 隐藏 vxe-table 内置 empty-placeholder,防止有数据时仍显示“暂无数据” - 隐藏小数据量下 vxe-table 错误的 .vxe-body--y-space 占位,消除表头与首行之间的 132px 间隙 - test_user_service.py: 中文化错误信息断言同步更新
This commit is contained in:
parent
99a512c3f7
commit
fede996dcb
|
|
@ -5,12 +5,13 @@ version: "3.8"
|
|||
# =============================================================================
|
||||
# 用法:docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d redis postgres
|
||||
#
|
||||
# 仅启动 redis + postgres(agentkit 在宿主机运行),映射端口到 6381/5435
|
||||
# 仅启动 redis + postgres(agentkit 在宿主机运行),映射端口到 6391/5445
|
||||
# 避免与 pms-redis(6379) / geo_redis(6380) / geo_db(5433) 端口冲突
|
||||
#
|
||||
# .env.dev 应包含:
|
||||
# REDIS_URL=redis://127.0.0.1:6381/0
|
||||
# DATABASE_URL=postgresql+asyncpg://agentkit:agentkit@127.0.0.1:5435/agentkit
|
||||
# 注意:端口须与 .env / agentkit.yaml 保持一致。
|
||||
# .env 应包含:
|
||||
# REDIS_URL=redis://127.0.0.1:6391/0
|
||||
# DATABASE_URL=postgresql+asyncpg://agentkit:agentkit@127.0.0.1:5445/agentkit
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
|
@ -20,8 +21,8 @@ services:
|
|||
|
||||
redis:
|
||||
ports:
|
||||
- "6381:6379"
|
||||
- "6391:6379"
|
||||
|
||||
postgres:
|
||||
ports:
|
||||
- "5435:5432"
|
||||
- "5445:5432"
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@
|
|||
# Python >= 3.11, Node.js >= 18, Redis, PostgreSQL (均自动检查)
|
||||
# --tauri 需要:Rust 工具链(rustup / brew install rust)
|
||||
#
|
||||
# 端口(与 .env.dev 保持一致):
|
||||
# 端口(与 agentkit.yaml / vite.config.ts 保持一致):
|
||||
# 18001 — AgentKit 后端 API
|
||||
# 18002 — Web GUI(前端 + 内置静态服务)
|
||||
# 15173 — Vite 开发服务器(--tauri 模式)
|
||||
# 15174 — Vite HMR websocket
|
||||
# 6381 — Redis(agentkit 专属容器,避免与 pms-redis:6379 / geo_redis:6380 冲突)
|
||||
# 5435 — PostgreSQL+pgvector(agentkit 专属容器,避免与 geo_db:5433 冲突)
|
||||
#
|
||||
# 中间件端口已与 .env / agentkit.yaml 对齐:
|
||||
# Redis → 6391
|
||||
# PostgreSQL → 5445
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
|
|
@ -33,6 +35,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 确保 Rust 工具链在 PATH 中(AI 终端 / 某些 shell 不会自动加载 ~/.cargo/env)
|
||||
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
# ── 颜色 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
RED='\033[0;31m'
|
||||
|
|
@ -63,8 +69,8 @@ Fischer AgentKit — 本地开发环境启动
|
|||
18001 — 后端 API
|
||||
18002 — Web GUI / 前端静态服务
|
||||
15173 — Vite 开发服务器(--tauri 模式)
|
||||
6381 — Redis(agentkit 专属容器)
|
||||
5435 — PostgreSQL+pgvector(agentkit 专属容器)
|
||||
6391 — Redis(agentkit 专属容器)
|
||||
5445 — PostgreSQL+pgvector(agentkit 专属容器)
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -122,8 +128,8 @@ print_status() {
|
|||
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
|
||||
echo -e "$([[ $S_DEPS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_DEPS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 依赖检查"
|
||||
echo -e "$([[ $S_ENV -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_ENV -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 环境配置"
|
||||
echo -e "$([[ $S_REDIS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_REDIS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") Redis (:6381)"
|
||||
echo -e "$([[ $S_PG -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_PG -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") PostgreSQL (:5435)"
|
||||
echo -e "$([[ $S_REDIS -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_REDIS -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") Redis (:6391)"
|
||||
echo -e "$([[ $S_PG -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_PG -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") PostgreSQL (:5445)"
|
||||
echo -e "$([[ $S_BACKEND -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_BACKEND -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 后端服务 (:18001)"
|
||||
if [[ $MODE == "gui" || $MODE == "tauri" ]]; then
|
||||
echo -e "$([[ $S_FRONTEND -eq 2 ]] && echo " ${GREEN}OK${NC}" || [[ $S_FRONTEND -eq 3 ]] && echo " ${RED}FAIL${NC}" || echo " ${YELLOW}..${NC}") 前端服务 (:18002)"
|
||||
|
|
@ -190,18 +196,18 @@ check_redis() {
|
|||
section "检查 Redis"
|
||||
set_status redis 1
|
||||
|
||||
# agentkit 专属容器映射到 6381,避免与 pms-redis(6379) / geo_redis(6380) 冲突
|
||||
if redis-cli -h 127.0.0.1 -p 6381 ping 2>/dev/null | grep -q PONG; then
|
||||
ok "Redis 运行中 (127.0.0.1:6381, agentkit 专属容器)"
|
||||
# agentkit 专属容器映射到 6391,避免与 pms-redis(6379) / geo_redis(6380) 冲突
|
||||
if redis-cli -h 127.0.0.1 -p 6391 ping 2>/dev/null | grep -q PONG; then
|
||||
ok "Redis 运行中 (127.0.0.1:6391, agentkit 专属容器)"
|
||||
set_status redis 2
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "Redis 未运行 (6381),启动 agentkit 专属 Docker 容器..."
|
||||
warn "Redis 未运行 (6391),启动 agentkit 专属 Docker 容器..."
|
||||
if docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d redis &>/dev/null; then
|
||||
sleep 2
|
||||
if redis-cli -h 127.0.0.1 -p 6381 ping 2>/dev/null | grep -q PONG; then
|
||||
ok "Redis Docker 容器启动成功 (127.0.0.1:6381)"
|
||||
if redis-cli -h 127.0.0.1 -p 6391 ping 2>/dev/null | grep -q PONG; then
|
||||
ok "Redis Docker 容器启动成功 (127.0.0.1:6391)"
|
||||
set_status redis 2
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -215,18 +221,18 @@ check_postgres() {
|
|||
section "检查 PostgreSQL"
|
||||
set_status pg 1
|
||||
|
||||
# agentkit 专属容器映射到 5435,避免与 geo_db(5433) 冲突
|
||||
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5435 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
|
||||
ok "PostgreSQL 运行中 (127.0.0.1:5435, agentkit 专属容器)"
|
||||
# agentkit 专属容器映射到 5445,避免与 geo_db(5433) 冲突
|
||||
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5445 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
|
||||
ok "PostgreSQL 运行中 (127.0.0.1:5445, agentkit 专属容器)"
|
||||
set_status pg 2
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "PostgreSQL 未运行 (5435),启动 agentkit 专属 Docker 容器..."
|
||||
warn "PostgreSQL 未运行 (5445),启动 agentkit 专属 Docker 容器..."
|
||||
if docker compose -f docker-compose.yaml -f docker-compose.dev.yml up -d postgres &>/dev/null; then
|
||||
sleep 3
|
||||
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5435 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
|
||||
ok "PostgreSQL Docker 容器启动成功 (127.0.0.1:5435)"
|
||||
if PGPASSWORD=agentkit psql -h 127.0.0.1 -p 5445 -U agentkit -d agentkit -c "SELECT 1" &>/dev/null; then
|
||||
ok "PostgreSQL Docker 容器启动成功 (127.0.0.1:5445)"
|
||||
set_status pg 2
|
||||
return 0
|
||||
fi
|
||||
|
|
@ -273,8 +279,7 @@ start_backend() {
|
|||
set_status backend 1
|
||||
|
||||
info "启动后端 API (:18001)..."
|
||||
source .venv/bin/activate
|
||||
agentkit serve --port 18001 &
|
||||
.venv/bin/python -m agentkit serve --port 18001 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 等待健康检查就绪(最多 60 秒)
|
||||
|
|
@ -310,8 +315,7 @@ start_gui() {
|
|||
set_status frontend 1
|
||||
|
||||
info "启动 Web GUI (:18002)..."
|
||||
source .venv/bin/activate
|
||||
agentkit gui --port 18002 &
|
||||
.venv/bin/python -m agentkit gui --port 18002 &
|
||||
GUI_PID=$!
|
||||
|
||||
# 等待就绪
|
||||
|
|
@ -347,11 +351,11 @@ start_tauri() {
|
|||
|
||||
local FE_DIR="$PROJECT_ROOT/src/agentkit/server/frontend"
|
||||
|
||||
if ! command -v tauri &>/dev/null; then
|
||||
warn "Tauri CLI 未安装,跳过桌面客户端"
|
||||
info "安装方式:npm install -g @tauri-apps/cli"
|
||||
set_status tauri 2
|
||||
return 0
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
warn "未检测到 Rust/Cargo,跳过 Tauri 桌面客户端"
|
||||
info "安装方式:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||
set_status tauri 3
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "启动 Tauri 桌面客户端..."
|
||||
|
|
@ -391,15 +395,19 @@ stop_services() {
|
|||
echo ""
|
||||
info "正在停止所有服务..."
|
||||
|
||||
for port in 18001 18002 15173 15174; do
|
||||
# 与 dev-stop.sh 保持一致的端口列表
|
||||
local DEV_PORTS=(18001 18002 15173 15174 8000 8001 8002 5173 5175)
|
||||
for port in "${DEV_PORTS[@]}"; do
|
||||
local pid=$(lsof -ti :$port 2>/dev/null || true)
|
||||
if [[ -n "$pid" ]]; then
|
||||
kill $pid 2>/dev/null && ok "端口 $port 已停止" || true
|
||||
kill -9 $pid 2>/dev/null && ok "端口 $port 已停止" || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Vite 进程
|
||||
pkill -f "vite" 2>/dev/null && ok "Vite 进程已停止" || true
|
||||
# Vite / esbuild 进程
|
||||
pkill -f "vite --port 15173" 2>/dev/null && ok "Vite(:15173) 已停止" || true
|
||||
pkill -f "vite --port 5175" 2>/dev/null && ok "Vite(:5175) 已停止" || true
|
||||
pkill -f "esbuild.*ping" 2>/dev/null && ok "esbuild 服务进程已停止" || true
|
||||
|
||||
echo ""
|
||||
ok "所有服务已停止。感谢使用!"
|
||||
|
|
@ -425,6 +433,10 @@ check_env
|
|||
# 初始化数据库
|
||||
[[ $INIT_DB -eq 1 ]] && init_db
|
||||
|
||||
# 先清理历史残留进程,避免端口冲突(特别是 8002/5173 等旧实例)
|
||||
section "清理历史进程"
|
||||
"$SCRIPT_DIR/dev-stop.sh" || true
|
||||
|
||||
# 安装依赖
|
||||
install_deps
|
||||
|
||||
|
|
|
|||
|
|
@ -9,16 +9,26 @@ cd "$PROJECT_ROOT"
|
|||
|
||||
echo "停止 Fischer AgentKit 开发服务..."
|
||||
|
||||
# 当前规范端口(与 agentkit.yaml / vite.config.ts 保持一致)
|
||||
# 18001 — AgentKit 后端 API
|
||||
# 18002 — Web GUI 内置静态服务
|
||||
# 15173 — Vite 开发服务器(Tauri 模式)
|
||||
# 15174 — Vite HMR websocket
|
||||
# 遗留端口一并清理,避免历史残留造成冲突
|
||||
DEV_PORTS=(18001 18002 15173 15174 8000 8001 8002 5173 5175)
|
||||
|
||||
# 停止后端/API/GUI 进程(按端口)
|
||||
for port in 8000 8001 8002 5173; do
|
||||
for port in "${DEV_PORTS[@]}"; do
|
||||
PID=$(lsof -ti :$port 2>/dev/null || true)
|
||||
if [[ -n "$PID" ]]; then
|
||||
kill $PID 2>/dev/null && echo " [OK] 端口 $port 已停止 (PID $PID)" || true
|
||||
kill -9 $PID 2>/dev/null && echo " [OK] 端口 $port 已停止 (PID $PID)" || true
|
||||
fi
|
||||
done
|
||||
|
||||
# 停止 Vite/Node 进程
|
||||
pkill -f "vite" 2>/dev/null && echo " [OK] Vite 进程已停止" || true
|
||||
# 停止 Vite / esbuild 进程(按命令名匹配,避免误杀其他 node 服务)
|
||||
pkill -f "vite --port 15173" 2>/dev/null && echo " [OK] Vite(:15173) 已停止" || true
|
||||
pkill -f "vite --port 5175" 2>/dev/null && echo " [OK] Vite(:5175) 已停止" || true
|
||||
pkill -f "esbuild.*ping" 2>/dev/null && echo " [OK] esbuild 服务进程已停止" || true
|
||||
|
||||
# 停止 Docker 容器(开发专用)
|
||||
for container in fischer-redis-dev fischer-pg-dev; do
|
||||
|
|
|
|||
|
|
@ -27,14 +27,16 @@
|
|||
v-else
|
||||
v-show="section.node === null || !isCollapsed(section.node)"
|
||||
class="bitable-grid-scope__group-grid"
|
||||
:class="{ 'bitable-grid-scope__group-grid--auto': !effectiveHeight }"
|
||||
>
|
||||
<vxe-grid
|
||||
:ref="(el: unknown) => onGridRef(idx, el)"
|
||||
:data="section.node ? rowsForGroup(section.node) : rows"
|
||||
:columns="gridColumns"
|
||||
:height="groupingEnabled ? 'auto' : height"
|
||||
:height="effectiveHeight"
|
||||
:loading="loading"
|
||||
:row-config="rowConfig"
|
||||
:checkbox-config="{ highlight: true, checkField: '_selected' }"
|
||||
:cell-config="{ height: 44 }"
|
||||
:header-config="{ height: 40 }"
|
||||
:column-config="{ resizable: true }"
|
||||
|
|
@ -50,9 +52,6 @@
|
|||
@cell-click="onCellClick"
|
||||
@cell-dblclick="onCellDblclick"
|
||||
>
|
||||
<template #empty>
|
||||
<a-empty :description="emptyText" />
|
||||
</template>
|
||||
<!-- Custom cell renderers for attachment/image fields (U6) -->
|
||||
<template
|
||||
v-for="f in attachmentFields"
|
||||
|
|
@ -141,6 +140,15 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state rendered outside vxe-grid to avoid vxe-table empty-slot
|
||||
lingering over populated rows when data arrives after initial mount. -->
|
||||
<div
|
||||
v-if="props.records.length === 0 && !props.loading"
|
||||
class="bitable-grid-scope__empty"
|
||||
>
|
||||
<a-empty :description="emptyText" />
|
||||
</div>
|
||||
|
||||
<!-- U3: Record detail drawer — opened by clicking the row's seq cell.
|
||||
Rendered here so the grid is self-contained; reads state from the
|
||||
bitable store (currentRecordId / currentRecord / fields). -->
|
||||
|
|
@ -253,6 +261,17 @@ const groupingEnabled = computed(
|
|||
() => groupByItems.value.length > 0 && props.records.length > 0,
|
||||
)
|
||||
|
||||
// ponytail: vxe-table creates a phantom 132px y-space spacer when a small
|
||||
// dataset is rendered with height="100%" in a tall container. Drop the
|
||||
// height prop for small / grouped datasets so the grid sizes to content;
|
||||
// keep percentage height for large datasets where virtual scrolling is
|
||||
// actually needed.
|
||||
const effectiveHeight = computed<string | number | undefined>(() => {
|
||||
if (groupingEnabled.value) return undefined
|
||||
if (props.records.length <= 60) return undefined
|
||||
return props.height
|
||||
})
|
||||
|
||||
// Build the nested group tree. Uses store.fields (all fields, not just
|
||||
// visible ones) so number-field aggregations work even when the field is
|
||||
// hidden by the view's hidden_fields config.
|
||||
|
|
@ -344,7 +363,7 @@ function rowClassName({ row }: { row: unknown; rowIndex: number }): string {
|
|||
// className function so vxe-table calls it for every data row.
|
||||
const rowConfig = computed(() => ({
|
||||
keyField: '_recordId',
|
||||
className: rowClassName as unknown as string,
|
||||
className: rowClassName,
|
||||
}))
|
||||
|
||||
function fieldName(fieldId: string): string {
|
||||
|
|
@ -424,17 +443,23 @@ const rows = computed<GridRow[]>(() =>
|
|||
|
||||
// Build vxe-grid columns from field definitions
|
||||
const gridColumns = computed<GridColumn[]>(() => {
|
||||
// ponytail: fixed columns + auto height trigger vxe-table layout bugs
|
||||
// (phantom y-space + misplaced fixed wrappers). Disable fixed columns
|
||||
// for small datasets; keep them for large datasets where horizontal
|
||||
// scrolling makes fixed columns valuable.
|
||||
const fixedLeft = props.records.length > 60 ? 'left' : undefined
|
||||
const cols: GridColumn[] = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
fixed: fixedLeft,
|
||||
resizable: false,
|
||||
editRender: { enabled: false },
|
||||
},
|
||||
{
|
||||
type: 'seq',
|
||||
width: 40,
|
||||
fixed: 'left',
|
||||
fixed: fixedLeft,
|
||||
title: '#',
|
||||
resizable: false,
|
||||
},
|
||||
|
|
@ -449,7 +474,7 @@ const gridColumns = computed<GridColumn[]>(() => {
|
|||
field: '__add_field',
|
||||
title: '',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
fixed: props.records.length > 60 ? 'right' : undefined,
|
||||
resizable: false,
|
||||
editRender: { enabled: false },
|
||||
slots: { header: 'header__add_field' },
|
||||
|
|
@ -568,6 +593,16 @@ defineExpose({
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bitable-grid-scope__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* KTD10: CSS isolation — all vxe-table style overrides scoped to
|
||||
|
|
@ -576,6 +611,44 @@ defineExpose({
|
|||
font-size: var(--bitable-font-sm);
|
||||
}
|
||||
|
||||
/* Hide vxe-table's built-in empty block + placeholder; we render empty state
|
||||
ourselves outside the grid to avoid the slot lingering over populated rows
|
||||
after data loads. */
|
||||
.bitable-grid-scope :deep(.vxe-table--empty-block),
|
||||
.bitable-grid-scope :deep(.vxe-table--empty-placeholder) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Make vxe-table checkboxes visible in dark theme. The default font-icon
|
||||
color is too close to the background; we draw a plain box instead. */
|
||||
.bitable-grid-scope :deep(.vxe-cell--checkbox) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bitable-grid-scope :deep(.vxe-checkbox--icon) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--bitable-color-border-hover);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bitable-color-bg);
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bitable-grid-scope :deep(.vxe-checkbox--icon.vxe-table-icon-checkbox-checked) {
|
||||
background-color: var(--bitable-color-primary);
|
||||
border-color: var(--bitable-color-primary);
|
||||
}
|
||||
|
||||
.bitable-grid-scope :deep(.vxe-checkbox--icon.vxe-table-icon-checkbox-indeterminate) {
|
||||
background-color: var(--bitable-color-primary);
|
||||
border-color: var(--bitable-color-primary);
|
||||
}
|
||||
|
||||
.bitable-grid-scope :deep(.vxe-header--column) {
|
||||
background: var(--bitable-color-bg-secondary);
|
||||
font-weight: 600;
|
||||
|
|
@ -679,9 +752,19 @@ defineExpose({
|
|||
}
|
||||
|
||||
.bitable-grid-scope__group-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--bitable-color-border);
|
||||
}
|
||||
|
||||
/* Auto-height grids (small datasets) should not have a virtual-scroll
|
||||
y-space spacer; vxe-table incorrectly reserves row-height space there,
|
||||
creating a phantom gap between the header and the first data row. */
|
||||
.bitable-grid-scope__group-grid--auto :deep(.vxe-body--y-space) {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
/* ── U5: Conditional Formatting (8 colors + bold) ─────────────────────── */
|
||||
/* Row-level classes applied via row-config.className. Group headers are
|
||||
separate <div> elements, so they're naturally unaffected by CF. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import VXETable from 'vxe-table'
|
||||
import './styles'
|
||||
import './styles/bitable-tokens.css'
|
||||
import App from './App.vue'
|
||||
|
|
@ -11,6 +12,7 @@ const app = createApp(App)
|
|||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(VXETable)
|
||||
|
||||
// Kick off the cold-start auth probe BEFORE mount so that
|
||||
// ``router.beforeEach`` can ``await authStore.waitForStartup()`` instead
|
||||
|
|
|
|||
|
|
@ -466,7 +466,13 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
|
|||
padding: var(--bitable-spacing-sm) var(--bitable-spacing-lg);
|
||||
border-bottom: 1px solid var(--bitable-color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__toolbar-left {
|
||||
|
|
@ -474,14 +480,18 @@ async function handleDeleteField(field: IBitableField): Promise<void> {
|
|||
align-items: center;
|
||||
gap: var(--bitable-spacing-sm);
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--bitable-spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bitable-file-detail-view__record-count {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fischer AgentKit</title>
|
||||
<script type="module" crossorigin src="/assets/index-agwA6wam.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BYNVyxSE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ls4ZdRZM.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -151,9 +151,7 @@ async def _fetch_user_row(db_path: Path, user_id: str) -> dict[str, Any] | None:
|
|||
|
||||
class TestCreateUserHappyPath:
|
||||
async def test_create_returns_user_dict(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
assert user["id"]
|
||||
assert user["username"] == "alice"
|
||||
assert user["email"] == "alice@example.com"
|
||||
|
|
@ -206,13 +204,9 @@ class TestCreateUserHappyPath:
|
|||
dept_ids = {d["id"] for d in user["departments"]}
|
||||
assert dept_ids == {eng["id"], hr["id"]}
|
||||
|
||||
async def test_password_is_bcrypt_hashed(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
async def test_password_is_bcrypt_hashed(self, service: UserService, fresh_db: Path):
|
||||
"""The stored password_hash must be a bcrypt hash, not plaintext."""
|
||||
user = await service.create_user(
|
||||
fresh_db, "eve", "eve@example.com", "MyPassword123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "eve", "eve@example.com", "MyPassword123!")
|
||||
row = await _fetch_user_row(fresh_db, user["id"])
|
||||
assert row is not None
|
||||
stored_hash = row["password_hash"]
|
||||
|
|
@ -232,19 +226,13 @@ class TestCreateUserEdgeCases:
|
|||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
with pytest.raises(ValueError, match="username"):
|
||||
await service.create_user(
|
||||
fresh_db, "alice", "other@example.com", "Secret123!"
|
||||
)
|
||||
with pytest.raises(ValueError, match="用户名"):
|
||||
await service.create_user(fresh_db, "alice", "other@example.com", "Secret123!")
|
||||
|
||||
async def test_duplicate_email_raises_value_error(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
async def test_duplicate_email_raises_value_error(self, service: UserService, fresh_db: Path):
|
||||
await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
with pytest.raises(ValueError, match="email"):
|
||||
await service.create_user(
|
||||
fresh_db, "alice2", "alice@example.com", "Secret123!"
|
||||
)
|
||||
with pytest.raises(ValueError, match="邮箱"):
|
||||
await service.create_user(fresh_db, "alice2", "alice@example.com", "Secret123!")
|
||||
|
||||
async def test_create_with_nonexistent_department_id_raises(
|
||||
self,
|
||||
|
|
@ -292,12 +280,8 @@ class TestListUsers:
|
|||
names = {u["username"] for u in users}
|
||||
assert names == {"alice", "bob"}
|
||||
|
||||
async def test_list_excludes_inactive_when_asked(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
alice = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
async def test_list_excludes_inactive_when_asked(self, service: UserService, fresh_db: Path):
|
||||
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
|
||||
# Soft-delete alice.
|
||||
await service.delete_user(fresh_db, alice["id"])
|
||||
|
|
@ -316,12 +300,8 @@ class TestListUsers:
|
|||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
hr = await _create_test_department(department_service, fresh_db, "HR")
|
||||
|
||||
alice = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
bob = await service.create_user(
|
||||
fresh_db, "bob", "bob@example.com", "Secret123!"
|
||||
)
|
||||
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
bob = await service.create_user(fresh_db, "bob", "bob@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, alice["id"], eng["id"])
|
||||
await service.assign_department(fresh_db, bob["id"], hr["id"])
|
||||
|
||||
|
|
@ -340,9 +320,7 @@ class TestListUsers:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
alice = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
alice = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, alice["id"], eng["id"])
|
||||
users = await service.list_users(fresh_db)
|
||||
assert len(users) == 1
|
||||
|
|
@ -364,9 +342,7 @@ class TestGetUser:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
fetched = await service.get_user(fresh_db, user["id"])
|
||||
assert fetched is not None
|
||||
|
|
@ -374,9 +350,7 @@ class TestGetUser:
|
|||
assert len(fetched["departments"]) == 1
|
||||
assert fetched["departments"][0]["name"] == "Engineering"
|
||||
|
||||
async def test_get_returns_none_for_unknown_id(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
async def test_get_returns_none_for_unknown_id(self, service: UserService, fresh_db: Path):
|
||||
result = await service.get_user(fresh_db, str(uuid.uuid4()))
|
||||
assert result is None
|
||||
|
||||
|
|
@ -388,25 +362,17 @@ class TestGetUser:
|
|||
|
||||
class TestUpdateUser:
|
||||
async def test_update_role(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
updated = await service.update_user(fresh_db, user["id"], role="admin")
|
||||
assert updated["role"] == "admin"
|
||||
|
||||
async def test_update_is_active(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
updated = await service.update_user(fresh_db, user["id"], is_active=False)
|
||||
assert updated["is_active"] is False
|
||||
|
||||
async def test_update_terminal_authorized_flags(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
async def test_update_terminal_authorized_flags(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
updated = await service.update_user(
|
||||
fresh_db,
|
||||
user["id"],
|
||||
|
|
@ -419,9 +385,7 @@ class TestUpdateUser:
|
|||
async def test_update_partial_only_changes_provided_fields(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
# Only update role; other fields should be preserved.
|
||||
updated = await service.update_user(fresh_db, user["id"], role="admin")
|
||||
assert updated["role"] == "admin"
|
||||
|
|
@ -434,12 +398,8 @@ class TestUpdateUser:
|
|||
with pytest.raises(ValueError, match="not found"):
|
||||
await service.update_user(fresh_db, str(uuid.uuid4()), role="admin")
|
||||
|
||||
async def test_update_no_fields_provided_is_noop(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
async def test_update_no_fields_provided_is_noop(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
updated = await service.update_user(fresh_db, user["id"])
|
||||
assert updated["username"] == "alice"
|
||||
|
||||
|
|
@ -450,20 +410,14 @@ class TestUpdateUser:
|
|||
|
||||
|
||||
class TestDeleteUser:
|
||||
async def test_delete_returns_true_for_active_user(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
async def test_delete_returns_true_for_active_user(self, service: UserService, fresh_db: Path):
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
deleted = await service.delete_user(fresh_db, user["id"])
|
||||
assert deleted is True
|
||||
|
||||
async def test_delete_is_soft(self, service: UserService, fresh_db: Path):
|
||||
"""delete_user must set is_active=0 but NOT remove the row."""
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.delete_user(fresh_db, user["id"])
|
||||
# Row must still exist.
|
||||
row = await _fetch_user_row(fresh_db, user["id"])
|
||||
|
|
@ -471,18 +425,14 @@ class TestDeleteUser:
|
|||
# But is_active must be 0.
|
||||
assert bool(row["is_active"]) is False
|
||||
|
||||
async def test_delete_returns_false_for_unknown_id(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
async def test_delete_returns_false_for_unknown_id(self, service: UserService, fresh_db: Path):
|
||||
deleted = await service.delete_user(fresh_db, str(uuid.uuid4()))
|
||||
assert deleted is False
|
||||
|
||||
async def test_delete_returns_false_for_already_inactive(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
first = await service.delete_user(fresh_db, user["id"])
|
||||
assert first is True
|
||||
# Second delete on the same (now-inactive) user must return False.
|
||||
|
|
@ -502,9 +452,7 @@ class TestResetPassword:
|
|||
session_service: SessionService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "OldPassword123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
|
||||
reset = await service.reset_password(fresh_db, user["id"], "NewPassword456!")
|
||||
assert reset is True
|
||||
|
||||
|
|
@ -514,9 +462,7 @@ class TestResetPassword:
|
|||
session_service: SessionService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "OldPassword123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
|
||||
old_row = await _fetch_user_row(fresh_db, user["id"])
|
||||
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
|
||||
new_row = await _fetch_user_row(fresh_db, user["id"])
|
||||
|
|
@ -530,9 +476,7 @@ class TestResetPassword:
|
|||
session_service: SessionService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "OldPassword123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
|
||||
# Create two active sessions for the user.
|
||||
await _create_session_for_user(fresh_db, user["id"])
|
||||
await _create_session_for_user(fresh_db, user["id"])
|
||||
|
|
@ -548,9 +492,7 @@ class TestResetPassword:
|
|||
session_service: SessionService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "OldPassword123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "OldPassword123!")
|
||||
session_id = await _create_session_for_user(fresh_db, user["id"])
|
||||
await service.reset_password(fresh_db, user["id"], "NewPassword456!")
|
||||
|
||||
|
|
@ -571,9 +513,7 @@ class TestResetPassword:
|
|||
session_service: SessionService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
reset = await service.reset_password(
|
||||
fresh_db, str(uuid.uuid4()), "NewPassword456!"
|
||||
)
|
||||
reset = await service.reset_password(fresh_db, str(uuid.uuid4()), "NewPassword456!")
|
||||
assert reset is False
|
||||
|
||||
|
||||
|
|
@ -590,9 +530,7 @@ class TestAssignDepartment:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
result = await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
assert result["user_id"] == user["id"]
|
||||
assert result["department_id"] == eng["id"]
|
||||
|
|
@ -605,9 +543,7 @@ class TestAssignDepartment:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
with pytest.raises(ValueError, match="already assigned"):
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
|
|
@ -617,9 +553,7 @@ class TestAssignDepartment:
|
|||
service: UserService,
|
||||
fresh_db: Path,
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
with pytest.raises(ValueError, match="Department.*not found"):
|
||||
await service.assign_department(fresh_db, user["id"], str(uuid.uuid4()))
|
||||
|
||||
|
|
@ -632,9 +566,7 @@ class TestRemoveDepartment:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
|
||||
assert removed is True
|
||||
|
|
@ -649,9 +581,7 @@ class TestRemoveDepartment:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
removed = await service.remove_department(fresh_db, user["id"], eng["id"])
|
||||
assert removed is False
|
||||
|
||||
|
|
@ -665,9 +595,7 @@ class TestListUserDepartments:
|
|||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
hr = await _create_test_department(department_service, fresh_db, "HR")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
await service.assign_department(fresh_db, user["id"], hr["id"])
|
||||
|
||||
|
|
@ -682,9 +610,7 @@ class TestListUserDepartments:
|
|||
async def test_list_returns_empty_for_user_with_no_departments(
|
||||
self, service: UserService, fresh_db: Path
|
||||
):
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
depts = await service.list_user_departments(fresh_db, user["id"])
|
||||
assert depts == []
|
||||
|
||||
|
|
@ -695,9 +621,7 @@ class TestListUserDepartments:
|
|||
fresh_db: Path,
|
||||
):
|
||||
eng = await _create_test_department(department_service, fresh_db, "Engineering")
|
||||
user = await service.create_user(
|
||||
fresh_db, "alice", "alice@example.com", "Secret123!"
|
||||
)
|
||||
user = await service.create_user(fresh_db, "alice", "alice@example.com", "Secret123!")
|
||||
await service.assign_department(fresh_db, user["id"], eng["id"])
|
||||
# Disable the department.
|
||||
await department_service.set_department_active(fresh_db, eng["id"], False)
|
||||
|
|
|
|||
Loading…
Reference in New Issue