## 严重问题
- order_timeout: 超时条件方向写反(`start_time < TIMESTAMPADD(HOUR,10,NOW())` 等价于
`start_time < NOW()+10h`,条件恒真)→ 活跃订单会被当超时单提前结掉。改为
`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`,并补"刚建单不命中/超期命中"双向断言
- C1 device-server 连接池可被未验签的报文 dev_id 顶替(覆盖式 insert + 断连无条件 remove):
改为 conn_id 归属模型 —— 唯一连接标识、remove_if/touch/is_authenticated 均带 conn 归属校验、
接管时用 oneshot 信号主动驱逐旧连接、每条上行前用 is_owner 复核持有者身份
- C2 POST /api/devices/{imei}/command 零归属校验 + act 自由字符串 + 把 IMEI 塞进协议 id:
补 scope_guard 归属校验 + act 白名单 + id 从 params 显式取(拒绝 15 位 IMEI)
- 非总管理员权限集恒为空(user_roles 从不写入且无分配入口)→ 全站 403:
user_roles 为空时按 users.role 回退内置角色权限 + 建/改用户幂等同步映射(仅角色变化时同步)
## 订单生命周期并发正确性
- finalize_order 先取电量累加器后抢结束权 → off/bat_out 成对到达时 energy 被写 0:
改为先原子抢占(WHERE compartment_id=? AND status=0 ORDER BY id DESC LIMIT 1),
抢到者才 take_and_reset 取电量;抢不到直接返回且不碰累加器
- 建单"先查后插"无锁无唯一约束 → 同仓体两笔活跃单:
条件插入(INSERT ... SELECT ... WHERE NOT EXISTS)+ 按仓体进程内锁
+ DB 层 active_flag 生成列 + UNIQUE 唯一索引(幂等迁移,有重复则记 ERROR 跳过,绝不阻断启动)
- handle_bat_out: updated==0 时不再复位占用;通道登记删除改为 battery_id 一致才删;
新增 should_finalize_bat_out 挡住"迟到 bat_out 结束新单"
- 电压/电流加量程夹取(0–100000 mV/mA 之外按 0 计并 WARN),防位翻转导致积分暴增
## 协议/契约
- spec 绝不可能是 "0000"(生产 42/68 台拓扑为空):新增 build_spec 三级兜底
(真实拓扑 → cabinets.cell×6 → 6×6),兜底生效记 WARN 报出拓扑数据
- /api/cabinets/realtime-status 改读真实字段(原来读的设备链路从不写的旧字段,恒返回 idle/空),
用 derive_channel_view 推导旧字段以保持响应字段名不变;修掉"把通道号当板号"的幽灵板问题
- 小程序通道详情/PC 详情改读通道 Hash(弃用无写入方的 compartment:status:{id}),
取不到返回 null 而非假的 0;补 voltage/current/temp/batt_status/pow_status
- 反馈列表补权限码 operation_log:view + status 筛选真正落到 SQL
- 小程序柜温换算(29800 曾被显示成 29800°C);签名注释 7→8 位;msg_id 序列化省略 null;
parse_compartment_id 与 status_post 补板号 1–15/通道 1–8 量程校验(不再写 board:0 垃圾 key)
## 文档
- docs/柜控板固件对接文档.md:确立数值口径统一规则(hex 字节流=原始硬件单位·平台换算;
JSON 数值字段=平台单位·设备换算)、修正 A.5.3 的 id 说明、作废变更记录原位标注(不删历史)、
spec 表述改为"按拓扑现算+兜底、恒 4 位不会是 0000"、修复文首 7 项表格被引用块切断丢表头、
修正 §10 误引 channels[].channel、写明无电池兜底阈值与 #16 风险
## 运维加固(服务器侧,非代码)
- 关闭 MySQL/Redis 公网暴露:nginx stream 由 0.0.0.0 改为只监听 127.0.0.1 + ZeroTier
- worker_shutdown_timeout 30s(原先每次 reload 泄漏永不退出的 worker)
- 测试证书改指向 app 证书(原测试证书 9 天后过期且无续期机制);补测试环境 uploads 目录;.env 0600
## 验收
- cargo check 两服务 0 error / 0 warning;cargo test api 88 + device 22 全过
- 路由 79 → 79 逐条一致;web tsc 干净
- 端到端联调(测试环境)13/13,订单结算与重构前逐字段一致;唯一索引已由迁移真实建出
- 生产已部署并验证:服务 active、health 200、ERROR 0、消费链路正常、迁移已建索引
268 lines
18 KiB
Vue
268 lines
18 KiB
Vue
<template>
|
||
<view class="home-page">
|
||
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px', height: (statusBarHeight + navBarHeight) + 'px', paddingRight: '110px' }">
|
||
<view class="org-switcher" @click="showOrgPicker = true">
|
||
<text class="org-label">{{ currentOrgName || '选择组织' }}</text>
|
||
<view class="org-arrow"><AppIcon name="arrow_down" :size="12" color="#111827" /></view>
|
||
</view>
|
||
<view class="scan-btn" @click="handleScan">
|
||
<image src="/static/scan-icon.png" style="width: 24px; height: 24px;" />
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="selectedOrgId !== null" class="project-filter">
|
||
<view class="filter-inner">
|
||
<view class="filter-tag" :class="{ active: selectedProjectId === null }" @click="selectProject(null)"><text>全部</text></view>
|
||
<view v-for="p in projects" :key="p.id" class="filter-tag" :class="{ active: selectedProjectId === p.id }" @click="selectProject(p.id)"><text>{{ p.name }}</text></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="stats-row">
|
||
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.online_cabinets) || 0 }}</text><text class="stat-label">在线设备</text></view>
|
||
<view class="stat-item"><text class="stat-value muted">{{ Number(stats.total_channels) || 0 }}</text><text class="stat-label">总通道</text></view>
|
||
<view class="stat-item"><text class="stat-value success">{{ Number(stats.idle_channels) || 0 }}</text><text class="stat-label">空闲</text></view>
|
||
<view class="stat-item"><text class="stat-value primary">{{ Number(stats.charging_count) || 0 }}</text><text class="stat-label">充电中</text></view>
|
||
<view class="stat-item"><text class="stat-value warning">{{ Number(stats.full_count) || 0 }}</text><text class="stat-label">充满</text></view>
|
||
<view class="stat-item"><text class="stat-value danger">{{ Number(stats.fault_count) || 0 }}</text><text class="stat-label">故障</text></view>
|
||
</view>
|
||
|
||
<view class="section-header"><text class="section-title">设备列表</text><text class="section-count">{{ cabinets.length }} 台</text></view>
|
||
|
||
<view v-if="cabinets.length === 0 && !loading" class="empty-state">
|
||
<text class="empty-text">暂无设备</text>
|
||
</view>
|
||
<view v-else class="device-list">
|
||
<view v-for="cab in cabinets" :key="cab.id" class="device-card" @click="goDetail(cab.id)">
|
||
<view class="device-header">
|
||
<view class="device-id-row">
|
||
<text class="device-id">{{ cab.abstract_id }}</text>
|
||
<view class="status-badge" :class="cab.status === 1 ? 'online' : 'offline'"><text>{{ cab.status === 1 ? '在线' : '离线' }}</text></view>
|
||
</view>
|
||
<view class="header-right">
|
||
<text v-if="cab.temp != null" class="temp-badge">{{ formatCelsius(cab.temp) }}</text>
|
||
<view class="signal-inline">
|
||
<view class="signal" :class="signalClass(cab.rssi)">
|
||
<view class="signal-bar l1"></view><view class="signal-bar l2"></view><view class="signal-bar l3"></view><view class="signal-bar l4"></view><view class="signal-bar l5"></view>
|
||
</view>
|
||
<text class="rssi-value">{{ cab.rssi ?? '—' }}</text>
|
||
</view>
|
||
<view class="pow-tags">
|
||
<!-- 三态:停电(红)/ 正常(绿)/ 未知(灰)—— 无数据绝不能显示成"正常" -->
|
||
<view class="pow-tag" :class="powerFlagOf(cab.pow_fail_dc, 'DC').tone"><text>{{ powerFlagOf(cab.pow_fail_dc, 'DC').label }}</text></view>
|
||
<view class="pow-tag" :class="powerFlagOf(cab.pow_fail_ac, 'AC').tone"><text>{{ powerFlagOf(cab.pow_fail_ac, 'AC').label }}</text></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<text class="device-name">{{ cab.name || '未命名' }}</text>
|
||
<text v-if="cab.imei" class="device-imei">IMEI: {{ cab.imei }}</text>
|
||
<text v-if="cab.address" class="device-addr">{{ cab.address }}</text>
|
||
<view class="channel-stats">
|
||
<view class="channel-chip total"><AppIcon name="grid" :size="14" color="#6b7280" /><text class="chip-num">{{ Number(cab.channel_count) || 0 }}</text></view>
|
||
<view class="channel-chip idle"><AppIcon name="battery" :size="14" color="#16a34a" /><text class="chip-num">{{ Number(cab.idle_count) || 0 }}</text></view>
|
||
<view class="channel-chip charging"><AppIcon name="battery_charging" :size="14" color="#0f766e" /><text class="chip-num">{{ Number(cab.charging_count) || 0 }}</text></view>
|
||
<view class="channel-chip full"><AppIcon name="battery_full" :size="14" color="#d97706" /><text class="chip-num">{{ Number(cab.full_count) || 0 }}</text></view>
|
||
<view class="channel-chip fault"><AppIcon name="alert" :size="14" color="#dc2626" /><text class="chip-num">{{ Number(cab.fault_count) || 0 }}</text></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showOrgPicker" class="org-overlay" @click="showOrgPicker = false">
|
||
<view class="org-sheet" @click.stop>
|
||
<view class="org-header"><text class="org-title">切换组织</text><view class="org-close" @click="showOrgPicker = false"><text>✕</text></view></view>
|
||
<scroll-view class="org-list" scroll-y>
|
||
<!-- 组织接口失败(如操作员缺少 org:view → 403)时给出可见提示,避免弹层静默为空 -->
|
||
<view v-if="orgs.length === 0" class="org-empty"><text>暂无可选组织(账号可能缺少「查看组织」权限),当前按「全部」展示</text></view>
|
||
<view class="org-option" :class="{ selected: selectedOrgId === null }" @click="switchOrgAll"><text>全部</text></view>
|
||
<view v-for="org in orgs" :key="org.id" class="org-option" :class="{ selected: selectedOrgId === org.id }" @click="switchOrg(org)"><text>{{ org.name }}</text></view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||
import { h5Api, type DashboardData, type CabinetItem, type OrgItem } from '../../api'
|
||
import { useAuthStore } from '../../store/auth'
|
||
import AppIcon from '../../components/AppIcon.vue'
|
||
import { formatCelsius, powerFlagOf } from '../../utils/deviceStatus'
|
||
|
||
const statusBarHeight = ref(47)
|
||
const navBarHeight = ref(44)
|
||
function calcNav() {
|
||
try {
|
||
const sys = wx.getSystemInfoSync()
|
||
statusBarHeight.value = sys.statusBarHeight || 47
|
||
const btn = wx.getMenuButtonBoundingClientRect()
|
||
navBarHeight.value = (btn && btn.height > 0) ? (btn.top - statusBarHeight.value) * 2 + btn.height : 44
|
||
} catch { statusBarHeight.value = 47; navBarHeight.value = 44 }
|
||
}
|
||
calcNav()
|
||
setTimeout(calcNav, 300)
|
||
setTimeout(calcNav, 600)
|
||
|
||
const authStore = useAuthStore()
|
||
|
||
function signalClass(rssi?: number): string {
|
||
if (!rssi || rssi < 30) return 'off'
|
||
if (rssi >= 55) return 'good'
|
||
if (rssi >= 40) return 'mid'
|
||
return 'low'
|
||
}
|
||
|
||
const stats = ref<DashboardData>({ online_cabinets: 0, total_channels: 0, charging_count: 0, idle_channels: 0, full_count: 0, fault_channels: 0, fault_count: 0, today_charge_count: 0, today_energy_kwh: 0, today_charge_hours: 0, alerts: [], projects: [] })
|
||
const projects = computed(() => stats.value.projects || [])
|
||
const cabinets = ref<CabinetItem[]>([])
|
||
const loading = ref(true)
|
||
const selectedProjectId = ref<number | null>(null)
|
||
const orgs = ref<OrgItem[]>([])
|
||
const showOrgPicker = ref(false)
|
||
const selectedOrgId = ref<number | null>(null)
|
||
const currentOrgName = ref('')
|
||
|
||
/**
|
||
* 加载组织列表并落到「全部」
|
||
*
|
||
* ⚠️ `GET /api/organizations` 需要 `org:view` 权限,操作员(normal_user)没有该权限 → 403。
|
||
* 前端兜底:**无论成功失败都切到「全部」**,失败时清空列表并给出可见提示
|
||
* (弹层内有提示行 + toast),绝不静默留一个空弹层。
|
||
* 若需让操作员看到自己组织下的设备,需后端提供免 `org:view` 的可见组织接口(前端无法单独解决)。
|
||
*/
|
||
async function initOrg() {
|
||
try {
|
||
const res = await h5Api.getOrganizations()
|
||
orgs.value = res || []
|
||
if (orgs.value.length === 0) {
|
||
uni.showToast({ title: '暂无可见组织,已按「全部」展示', icon: 'none' })
|
||
}
|
||
} catch {
|
||
orgs.value = []
|
||
uni.showToast({ title: '组织列表加载失败,已按「全部」展示', icon: 'none' })
|
||
}
|
||
// 统一落到「全部」:只改筛选状态,数据由 onShow 的 fetch 拉取,避免重复请求
|
||
applyOrgAll()
|
||
}
|
||
/** 切到「全部」(仅状态,不含请求) */
|
||
function applyOrgAll() { selectedOrgId.value = null; currentOrgName.value = '全部'; selectedProjectId.value = null }
|
||
function switchOrgAll() { showOrgPicker.value = false; applyOrgAll(); fetchDashboard(); fetchCabinets() }
|
||
function switchOrg(org: OrgItem) { showOrgPicker.value = false; selectedOrgId.value = org.id; currentOrgName.value = org.name; selectedProjectId.value = null; fetchDashboard(); fetchCabinets() }
|
||
|
||
onShow(async () => {
|
||
const scanId = authStore.scanAbstractId
|
||
if (scanId) {
|
||
authStore.clearScanId()
|
||
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
|
||
try { const res = await h5Api.getCabinetByAbstract(scanId); uni.navigateTo({ url: `/pages/device-detail/index?id=${res.id}` }); return } catch { uni.showToast({ title: '设备不存在', icon: 'none' }) }
|
||
}
|
||
if (!authStore.isAuthenticated) { uni.navigateTo({ url: '/pages/login/index' }); return }
|
||
initOrg(); fetchDashboard(); fetchCabinets()
|
||
})
|
||
onPullDownRefresh(async () => { await Promise.all([fetchDashboard(), fetchCabinets()]); uni.stopPullDownRefresh() })
|
||
|
||
async function fetchDashboard() {
|
||
try { const params: Record<string, string> = {}; if (selectedOrgId.value) params.org_id = String(selectedOrgId.value); const res = await h5Api.getDashboard(params); stats.value = res } catch {}
|
||
}
|
||
async function fetchCabinets() {
|
||
loading.value = true
|
||
try { const params: Record<string, any> = {}; if (selectedProjectId.value !== null) params.project_id = selectedProjectId.value; if (selectedOrgId.value) params.org_id = selectedOrgId.value; const res = await h5Api.getCabinets(Object.keys(params).length > 0 ? params : undefined); cabinets.value = res || [] } catch { cabinets.value = [] } finally { loading.value = false }
|
||
}
|
||
function selectProject(id: number | null) { selectedProjectId.value = id; fetchCabinets() }
|
||
function goDetail(id: number) { uni.navigateTo({ url: `/pages/device-detail/index?id=${id}` }) }
|
||
|
||
function handleScan() {
|
||
uni.scanCode({
|
||
scanType: ['qrCode'],
|
||
success: (res) => {
|
||
const code = res.result.trim()
|
||
const match = code.match(/\/(\d{2}-\d{8})$/) || code.match(/[?&]id=(\d{2}-\d{8})/)
|
||
const abstractId = match ? match[1] : code
|
||
if (/^\d{2}-\d{8}$/.test(abstractId)) { goToDeviceByScan(abstractId) }
|
||
else { uni.showToast({ title: '无效的二维码', icon: 'none' }) }
|
||
},
|
||
fail: () => { showScanInput() }
|
||
})
|
||
}
|
||
async function goToDeviceByScan(code: string) {
|
||
try { const result = await h5Api.scanCabinet(code); uni.navigateTo({ url: `/pages/device-detail/index?id=${result.id}` }) } catch (err: any) {
|
||
const msg = err?.message || ''
|
||
if (msg.includes('没有权限') || msg.includes('403') || msg.includes('Forbidden')) { uni.showToast({ title: '没有权限访问该设备', icon: 'none' }) } else if (msg.includes('不存在')) { uni.showToast({ title: '设备不存在', icon: 'none' }) } else { uni.showToast({ title: '扫码失败: ' + msg, icon: 'none' }) }
|
||
}
|
||
}
|
||
function showScanInput() {
|
||
uni.showModal({ title: '扫码', content: '请输入或粘贴二维码中的设备编号', editable: true, placeholderText: '如: 10-00000000', success: async (res) => { if (!res.confirm || !res.content?.trim()) return; goToDeviceByScan(res.content.trim()) } })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.home-page { background: #f5f7f8; min-height: 100vh; }
|
||
.top-bar { display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid #f1f5f9; position: sticky; top: 0; z-index: 100; box-sizing: border-box; }
|
||
.org-switcher { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: #f1f5f9; border-radius: 999px; }
|
||
.org-label { font-size: 14px; font-weight: 500; color: #111827; max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.org-arrow { display: flex; align-items: center; }
|
||
.scan-btn { display: flex; align-items: center; justify-content: center; background: none; color: #111827; border: none; padding: 0; }
|
||
.project-filter { padding: 12px 20px; background: #fff; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
||
.project-filter::-webkit-scrollbar { display: none; }
|
||
.filter-inner { display: flex; gap: 8px; white-space: nowrap; }
|
||
.filter-tag { flex-shrink: 0; padding: 6px 14px; font-size: 13px; border-radius: 999px; border: 1px solid #e2e8f0; color: #475569; background: #fff; }
|
||
.filter-tag.active { background: #0d9488; color: #fff; border-color: #0d9488; }
|
||
.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 16px 20px; }
|
||
.stat-item { background: #fff; border-radius: 12px; padding: 16px 12px; text-align: center; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
||
.stat-value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; display: block; }
|
||
.stat-value.primary { color: #0d9488; }
|
||
.stat-value.success { color: #22c55e; }
|
||
.stat-value.warning { color: #f59e0b; }
|
||
.stat-value.danger { color: #ef4444; }
|
||
.stat-value.muted { color: #94a3b8; }
|
||
.stat-label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
|
||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; }
|
||
.section-title { font-size: 16px; font-weight: 600; color: #111827; }
|
||
.section-count { font-size: 12px; color: #94a3b8; }
|
||
.device-list { padding: 0 20px; display: flex; flex-direction: column; gap: 12px; }
|
||
.device-card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
||
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||
.device-id-row { display: flex; align-items: center; gap: 8px; }
|
||
.device-id { font-size: 14px; font-weight: 600; color: #111827; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
|
||
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500; border-radius: 999px; }
|
||
.status-badge.online { background: #f0fdf4; color: #22c55e; }
|
||
.status-badge.offline { background: #f1f5f9; color: #94a3b8; }
|
||
.status-badge.online::before, .status-badge.offline::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||
.temp-badge { font-size: 12px; color: #475569; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||
.signal-inline { display: flex; align-items: center; gap: 3px; }
|
||
.signal { display: flex; align-items: flex-end; gap: 1.5px; }
|
||
.signal-bar { width: 3px; border-radius: 1px; }
|
||
.signal-bar.l1 { height: 4px; } .signal-bar.l2 { height: 7px; } .signal-bar.l3 { height: 10px; } .signal-bar.l4 { height: 13px; } .signal-bar.l5 { height: 16px; }
|
||
.signal.good .signal-bar { background: #16a34a; } .signal.mid .signal-bar { background: #f59e0b; } .signal.low .signal-bar { background: #ef4444; } .signal.off .signal-bar { background: #94a3b8; }
|
||
.rssi-value { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; }
|
||
.pow-tags { display: flex; gap: 4px; }
|
||
.pow-tag { font-size: 10px; font-weight: 500; padding: 2px 6px; border-radius: 3px; letter-spacing: 0.04em; white-space: nowrap; }
|
||
.pow-tag.on { background: #dcfce7; color: #16a34a; }
|
||
.pow-tag.off { background: #fee2e2; color: #dc2626; }
|
||
/* 无数据(null/缺失):灰色,与 PC 端「无数据」语义一致,不表示正常 */
|
||
.pow-tag.unknown { background: #f1f5f9; color: #94a3b8; }
|
||
.device-name { font-size: 14px; font-weight: 500; color: #111827; display: block; margin-bottom: 4px; }
|
||
.device-imei { font-size: 11px; color: #94a3b8; font-variant-numeric: tabular-nums; display: block; margin-bottom: 4px; opacity: 0.7; }
|
||
.device-addr { font-size: 12px; color: #475569; display: block; margin-bottom: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.channel-stats { display: flex; align-items: center; gap: 12px; padding-top: 12px; border-top: 1px solid #f1f5f9; }
|
||
.channel-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #475569; }
|
||
.channel-chip.total { color: #6b7280; }
|
||
.channel-chip.idle { color: #16a34a; }
|
||
.channel-chip.charging { color: #0f766e; }
|
||
.channel-chip.full { color: #d97706; }
|
||
.channel-chip.fault { color: #dc2626; }
|
||
.chip-num { font-variant-numeric: tabular-nums; font-weight: 600; color: #111827; }
|
||
.empty-state { text-align: center; padding: 64px 20px; color: #94a3b8; }
|
||
.empty-text { font-size: 14px; }
|
||
.org-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||
.org-sheet { background: #fff; width: 100%; max-width: 430px; max-height: 70vh; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; overflow: hidden; }
|
||
.org-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #f1f5f9; }
|
||
.org-title { font-size: 16px; font-weight: 600; }
|
||
.org-close { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #475569; }
|
||
.org-list { flex: 1; }
|
||
.org-empty { padding: 14px 20px; font-size: 12px; line-height: 18px; color: #94a3b8; background: #f8fafc; }
|
||
.org-option { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; font-size: 15px; color: #111827; border-bottom: 1px solid #f1f5f9; }
|
||
.org-option.selected { color: #0d9488; font-weight: 500; }
|
||
.org-option.selected::after { content: '✓'; font-size: 16px; font-weight: 600; }
|
||
</style>
|