Compare commits

10 Commits

Author SHA1 Message Date
279e453ad6 feat(web): add tooltip for charge point status with online/offline indication 2026-03-11 22:01:43 +08:00
02a361488b feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application
refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs
style(globals): improve CSS variable definitions for better readability and consistency
deps: add dayjs as a dependency for date manipulation
2026-03-11 21:34:21 +08:00
73f0c6243a feat(web): add remote start transaction feature and QR code scanning for charging 2026-03-11 18:09:00 +08:00
8ee2378c78 feat(web): integrate toast notifications for profile and passkey actions 2026-03-11 17:24:56 +08:00
168a5b5613 feat(web): integrate session management and improve API error handling 2026-03-11 17:19:14 +08:00
f1932676be feat(web): add SessionWatcher component for session management and handle session expiration 2026-03-11 17:08:52 +08:00
7bd4e379de feat: web docker 2026-03-11 16:59:56 +08:00
ce53a4f218 feat(csms): restructure deployment setup with Docker and improve build process 2026-03-11 15:39:31 +08:00
70ae7da0d9 feat(web): wrap LoginForm in Suspense for improved loading handling 2026-03-11 13:45:42 +08:00
9bdeea8a12 feat(web): add user ID and name to transaction details for better tracking 2026-03-11 12:09:15 +08:00
44 changed files with 1745 additions and 415 deletions

32
apps/csms/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:22-alpine AS base
FROM base AS builder
RUN apk add --no-cache gcompat
RUN corepack enable && corepack prepare pnpm@10.18.2 --activate
WORKDIR /app
# 复制 monorepo 根配置catalog、lockfile 等)
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
# 复制 csms 应用源码
COPY apps/csms/package.json ./apps/csms/package.json
COPY apps/csms/esbuild.config.js ./apps/csms/esbuild.config.js
COPY apps/csms/tsconfig.json ./apps/csms/tsconfig.json
COPY apps/csms/src ./apps/csms/src
RUN pnpm install --filter csms && \
pnpm --filter csms run build:prod
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono
COPY --from=builder --chown=hono:nodejs /app/apps/csms/dist /app/dist
COPY --from=builder --chown=hono:nodejs /app/apps/csms/package.json /app/package.json
USER hono
EXPOSE 3001
CMD ["node", "/app/dist/index.js"]

View File

@@ -1,16 +0,0 @@
FROM node:22-alpine
WORKDIR /app
# 复制应用文件
COPY package.json .
COPY index.js .
# 安装依赖
RUN npm ci --omit=dev
# 暴露端口
EXPOSE 3001
# 启动应用
CMD ["node", "index.js"]

View File

@@ -1,12 +0,0 @@
version: '3.8'
services:
csms:
build: .
ports:
- "3001:3001"
environment:
NODE_ENV: production
# env_file:
# - .env
restart: unless-stopped

View File

@@ -1 +0,0 @@
import{serve as c}from"@hono/node-server";import{Hono as a}from"hono";import{createNodeWebSocket as p}from"@hono/node-ws";import{getConnInfo as i}from"@hono/node-server/conninfo";import{cors as m}from"hono/cors";import{logger as l}from"hono/logger";import{showRoutes as g}from"hono/dev";import{readFileSync as d}from"fs";import{dirname as f,join as u}from"path";import{fileURLToPath as h}from"url";var S=f(h(import.meta.url)),s="1.0.0";try{let o=u(S,"../../package.json");s=JSON.parse(d(o,"utf-8")).version||"1.0.0"}catch{}var r=new a,{injectWebSocket:k,upgradeWebSocket:v}=p({app:r});r.use(l());r.use("/ocpp",m({origin:"*",allowMethods:["GET","POST","OPTIONS"],allowHeaders:["Content-Type","Authorization"],exposeHeaders:["Content-Length"],credentials:!0}));r.get("/",o=>o.json({platform:"Helios CSMS",version:s,message:"ok"}));r.get("/ocpp",v(o=>({onOpen(e,t){let n=i(o);console.log(`New connection from ${n.remote.address}:${n.remote.port}`)},onMessage(e,t){console.log(`Received message: ${e.data}`),t.send(`Echo: ${e.data}`)},onClose(e,t){console.log("Connection closed: ",e.code,e.reason)}})));g(r,{verbose:!0});var w=c({fetch:r.fetch,port:3001},o=>{console.log(`Server is running on http://localhost:${o.port}`)});k(w);

View File

@@ -1,13 +0,0 @@
{
"name": "csms",
"type": "module",
"dependencies": {
"@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0",
"better-auth": "^1.3.34",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",
"pg": "^8.16.3"
}
}

View File

@@ -10,7 +10,9 @@ const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
}
const externalModules = Object.keys(allDeps)
// 开发模式将依赖标记为 external 以加快构建速度;
// 生产模式全部打包进 bundlerunner 阶段无需 node_modules。
const externalModules = isProduction ? [] : Object.keys(allDeps)
const config = {
entryPoints: ['src/index.ts'],
@@ -22,6 +24,12 @@ const config = {
external: externalModules,
sourcemap: !isProduction,
minify: isProduction,
// CJS 包(如 dotenv在 ESM bundle 中需要 require 支持
banner: isProduction
? {
js: `import{createRequire}from'module';const require=createRequire(import.meta.url);`,
}
: {},
define: {
'process.env.NODE_ENV': `"${process.env.NODE_ENV || 'development'}"`,
},

View File

@@ -6,8 +6,6 @@
"build": "node esbuild.config.js",
"build:prod": "NODE_ENV=production node esbuild.config.js",
"start": "node dist/index.js",
"deploy": "node scripts/deploy.js",
"deploy:docker": "node scripts/deploy.js --docker",
"db:gen:auth": "npx @better-auth/cli generate --output src/db/auth-schema.ts",
"db:gen": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -19,6 +17,7 @@
"@hono/node-ws": "^1.2.0",
"@hono/zod-validator": "^0.7.6",
"better-auth": "catalog:",
"dayjs": "catalog:",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env node
/**
* 部署助手脚本
*
* 使用:
* node scripts/deploy.js # 标准部署(生产构建 + 依赖)
* node scripts/deploy.js --docker # Docker 镜像
* node scripts/deploy.js --help # 显示帮助
*/
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
const args = process.argv.slice(2)
const help = args.includes('--help') || args.includes('-h')
const isDocker = args.includes('--docker')
const outDir = args.includes('--out') ? args[args.indexOf('--out') + 1] : 'dist'
if (help) {
console.log(`
Usage: node scripts/deploy.js [options]
Options:
--docker 生成 Dockerfile
--out PATH 输出目录(默认: dist
--help 显示此帮助信息
Examples:
node scripts/deploy.js 生产构建
node scripts/deploy.js --out build 输出到 build 目录
node scripts/deploy.js --docker 生成 Docker 配置
`)
process.exit(0)
}
console.log('🚀 开始部署流程...\n')
try {
// 1. 生产构建
console.log('📦 构建应用...')
execSync('npm run build:prod', { stdio: 'inherit', cwd: process.cwd() })
// 2. 创建部署目录
console.log('\n📁 准备部署目录...')
const deployDir = path.join(process.cwd(), 'deploy')
if (fs.existsSync(deployDir)) {
fs.rmSync(deployDir, { recursive: true })
}
fs.mkdirSync(deployDir, { recursive: true })
// 3. 复制必要文件
console.log('📋 复制文件...')
// 复制构建输出
fs.copyFileSync(
path.join(process.cwd(), 'dist/index.js'),
path.join(deployDir, 'index.js'),
)
fs.copyFileSync(
path.join(process.cwd(), 'dist/package.json'),
path.join(deployDir, 'package.json'),
)
// 复制 package.json用于 npm install
const srcPkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
const deployPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: 'module',
dependencies: srcPkg.dependencies,
}
fs.writeFileSync(
path.join(deployDir, 'package.json'),
JSON.stringify(deployPkg, null, 2),
)
// 复制 .env 模板
if (fs.existsSync('.env.example')) {
fs.copyFileSync('.env.example', path.join(deployDir, '.env.example'))
}
console.log(`✅ 部署文件已生成到 ${path.relative(process.cwd(), deployDir)}/`)
// 4. 可选:生成 Docker 配置
if (isDocker) {
console.log('\n🐳 生成 Docker 配置...')
const dockerfile = `FROM node:22-alpine
WORKDIR /app
# 复制应用文件
COPY package.json .
COPY index.js .
# 安装依赖
RUN npm ci --omit=dev
# 暴露端口
EXPOSE 3001
# 启动应用
CMD ["node", "index.js"]
`
const dockerCompose = `version: '3.8'
services:
csms:
build: .
ports:
- "3001:3001"
environment:
NODE_ENV: production
# env_file:
# - .env
restart: unless-stopped
`
fs.writeFileSync(path.join(deployDir, 'Dockerfile'), dockerfile)
fs.writeFileSync(path.join(deployDir, 'docker-compose.yml'), dockerCompose)
console.log('✅ Docker 文件已生成')
}
// 5. 显示部署信息
console.log('\n📊 部署信息:')
console.log(` 名称: ${deployPkg.name}`)
console.log(` 版本: ${deployPkg.version}`)
console.log(` 主文件: index.js`)
console.log(` 依赖数: ${Object.keys(deployPkg.dependencies).length}`)
const indexSize = fs.statSync(path.join(deployDir, 'index.js')).size
console.log(` 代码大小: ${(indexSize / 1024).toFixed(1)}KB`)
console.log('\n✨ 部署准备完成!\n')
console.log('下一步:')
console.log(` 1. cd ${path.relative(process.cwd(), deployDir)}`)
console.log(` 2. npm install --omit=dev`)
console.log(` 3. node index.js`)
if (isDocker) {
console.log('\n或使用 Docker:')
console.log(` 1. cd ${path.relative(process.cwd(), deployDir)}`)
console.log(` 2. docker compose up`)
}
} catch (error) {
console.error('\n❌ 部署失败:', error.message)
process.exit(1)
}

View File

@@ -9,6 +9,7 @@ import { showRoutes } from 'hono/dev'
import { auth } from './lib/auth.ts'
import { createOcppHandler } from './ocpp/handler.ts'
import statsRoutes from './routes/stats.ts'
import statsChartRoutes from './routes/stats-chart.ts'
import chargePointRoutes from './routes/charge-points.ts'
import transactionRoutes from './routes/transactions.ts'
import idTagRoutes from './routes/id-tags.ts'
@@ -51,6 +52,7 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
// REST API routes
app.route('/api/stats', statsRoutes)
app.route('/api/stats/chart', statsChartRoutes)
app.route('/api/charge-points', chargePointRoutes)
app.route('/api/transactions', transactionRoutes)
app.route('/api/id-tags', idTagRoutes)

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import type {
@@ -24,7 +25,7 @@ export async function resolveIdTagInfo(
if (!tag) return { status: "Invalid" };
if (tag.status === "Blocked") return { status: "Blocked" };
if (tag.expiryDate && tag.expiryDate < new Date()) {
if (tag.expiryDate && dayjs(tag.expiryDate).isBefore(dayjs())) {
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
}
if (tag.status !== "Accepted") {

View File

@@ -1,4 +1,5 @@
import { useDrizzle } from '@/lib/db.js'
import dayjs from 'dayjs'
import { chargePoint } from '@/db/schema.js'
import type {
BootNotificationRequest,
@@ -30,7 +31,7 @@ export async function handleBootNotification(
// New, unknown devices start as Pending — admin must manually accept them
registrationStatus: 'Pending',
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(),
lastBootNotificationAt: dayjs().toDate(),
})
.onConflictDoUpdate({
target: chargePoint.chargePointIdentifier,
@@ -45,8 +46,8 @@ export async function handleBootNotification(
meterSerialNumber: payload.meterSerialNumber ?? null,
// Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(),
updatedAt: new Date(),
lastBootNotificationAt: dayjs().toDate(),
updatedAt: dayjs().toDate(),
},
})
.returning()
@@ -57,7 +58,7 @@ export async function handleBootNotification(
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
return {
currentTime: new Date().toISOString(),
currentTime: dayjs().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL,
status,
}

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import type {
@@ -15,10 +16,10 @@ export async function handleHeartbeat(
await db
.update(chargePoint)
.set({ lastHeartbeatAt: new Date() })
.set({ lastHeartbeatAt: dayjs().toDate() })
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return {
currentTime: new Date().toISOString(),
currentTime: dayjs().toISOString(),
}
}

View File

@@ -1,4 +1,5 @@
import { and, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
@@ -38,7 +39,7 @@ export async function handleMeterValues(
connectorId: conn.id,
chargePointId: cp.id,
connectorNumber: payload.connectorId,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
}));

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, transaction } from "@/db/schema.js";
import type {
@@ -35,16 +36,16 @@ export async function handleStartTransaction(
connectorId: payload.connectorId,
status: "Charging",
errorCode: "NoError",
lastStatusAt: new Date(),
lastStatusAt: dayjs().toDate(),
})
.onConflictDoUpdate({
target: [connector.chargePointId, connector.connectorId],
set: { status: "Charging", updatedAt: new Date() },
set: { status: "Charging", updatedAt: dayjs().toDate() },
})
.returning({ id: connector.id });
const rejected = idTagInfo.status !== "Accepted";
const now = new Date();
const now = dayjs();
// Insert transaction record regardless of auth status (OCPP spec requirement)
const [tx] = await db
@@ -55,12 +56,12 @@ export async function handleStartTransaction(
connectorNumber: payload.connectorId,
idTag: payload.idTag,
idTagStatus: idTagInfo.status,
startTimestamp: new Date(payload.timestamp),
startTimestamp: dayjs(payload.timestamp).toDate(),
startMeterValue: payload.meterStart,
reservationId: payload.reservationId ?? null,
// If rejected, immediately close the transaction so it doesn't appear as in-progress
...(rejected && {
stopTimestamp: now,
stopTimestamp: now.toDate(),
stopMeterValue: payload.meterStart,
chargeAmount: 0,
stopReason: "DeAuthorized",

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
import type {
@@ -54,7 +55,7 @@ export async function handleStatusNotification(
throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`)
}
const statusTimestamp = payload.timestamp ? new Date(payload.timestamp) : new Date()
const statusTimestamp = payload.timestamp ? dayjs(payload.timestamp).toDate() : dayjs().toDate()
const connStatus = payload.status as ConnectorStatus
const connErrorCode = payload.errorCode as ConnectorErrorCode
@@ -81,7 +82,7 @@ export async function handleStatusNotification(
vendorId: payload.vendorId ?? null,
vendorErrorCode: payload.vendorErrorCode ?? null,
lastStatusAt: statusTimestamp,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
},
})
.returning({ id: connector.id })

View File

@@ -1,4 +1,5 @@
import { eq, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import type {
@@ -18,11 +19,11 @@ export async function handleStopTransaction(
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopTimestamp: dayjs(payload.timestamp).toDate(),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(transaction.id, payload.transactionId))
.returning();
@@ -35,7 +36,7 @@ export async function handleStopTransaction(
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
@@ -49,7 +50,7 @@ export async function handleStopTransaction(
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
@@ -76,7 +77,7 @@ export async function handleStopTransaction(
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: new Date() })
.set({ chargeAmount: feeFen, updatedAt: dayjs().toDate() })
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
@@ -84,7 +85,7 @@ export async function handleStopTransaction(
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tx.idTag));
}

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
@@ -135,7 +136,7 @@ app.patch("/:id", async (c) => {
chargePointVendor?: string;
chargePointModel?: string;
updatedAt: Date;
} = { updatedAt: new Date() };
} = { updatedAt: dayjs().toDate() };
if (body.feePerKwh !== undefined) {
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -69,7 +70,7 @@ app.post("/", async (c) => {
.insert(idTag)
.values({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : null,
})
.returning();
return c.json(created, 201);
@@ -120,8 +121,8 @@ app.patch("/:id", async (c) => {
.update(idTag)
.set({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(),
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : undefined,
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tagId))
.returning();

View File

@@ -0,0 +1,168 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { transaction, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
/**
* GET /api/stats/chart?range=30d|7d|24h
* 返回时序图表数据按日30d/7d或小时24h分组
* 仅管理员可访问
*/
app.get("/", async (c) => {
const currentUser = c.get("user");
if (!currentUser || currentUser.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const range = c.req.query("range") ?? "7d";
const db = useDrizzle();
// 真实插口数量connectorId >= 1至少为 1 防止除零
const [{ cnt }] = await db
.select({ cnt: sql<number>`greatest(count(*)::int, 1)` })
.from(connector)
.where(sql`${connector.connectorId} >= 1`);
if (range === "24h") {
// 按小时分组,最近 24 小时
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('hour', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('hour', generate_series) + interval '1 hour')
- greatest(t.start_timestamp, date_trunc('hour', generate_series))
)) / (${sql.raw(String(cnt))} * 3600.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('hour', generate_series) + interval '1 hour'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('hour', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('hour', now() AT TIME ZONE 'UTC') - interval '23 hours',
date_trunc('hour', now() AT TIME ZONE 'UTC'),
interval '1 hour'
) AS generate_series
ORDER BY bucket ASC
`);
type Row24h = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as Row24h[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
}
// 按天分组7d 或 30d
const days = range === "30d" ? 29 : 6;
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('day', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('day', generate_series) + interval '1 day')
- greatest(t.start_timestamp, date_trunc('day', generate_series))
)) / (${sql.raw(String(cnt))} * 86400.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('day', generate_series) + interval '1 day'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('day', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('day', now() AT TIME ZONE 'UTC') - interval '${sql.raw(String(days))} days',
date_trunc('day', now() AT TIME ZONE 'UTC'),
interval '1 day'
) AS generate_series
ORDER BY bucket ASC
`);
type RowDay = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as RowDay[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
});
export default app;

View File

@@ -1,13 +1,89 @@
import { Hono } from "hono";
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
/**
* POST /api/transactions/remote-start
* Send RemoteStartTransaction to a charge point.
* Non-admin users can only use their own id-tags.
*/
app.post("/remote-start", async (c) => {
const currentUser = c.get("user");
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const db = useDrizzle();
const body = await c.req.json<{
chargePointIdentifier: string;
connectorId: number;
idTag: string;
}>().catch(() => null);
if (
!body ||
!body.chargePointIdentifier?.trim() ||
!Number.isInteger(body.connectorId) ||
body.connectorId < 1 ||
!body.idTag?.trim()
) {
return c.json(
{ error: "chargePointIdentifier, connectorId (>=1), and idTag are required" },
400,
);
}
// Non-admin: verify idTag belongs to current user and is Accepted
if (currentUser.role !== "admin") {
const [tag] = await db
.select({ status: idTag.status })
.from(idTag)
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
.limit(1);
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
}
// Verify charge point exists and is Accepted
const [cp] = await db
.select({ id: chargePoint.id, registrationStatus: chargePoint.registrationStatus })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, body.chargePointIdentifier.trim()))
.limit(1);
if (!cp) return c.json({ error: "ChargePoint not found" }, 404);
if (cp.registrationStatus !== "Accepted") {
return c.json({ error: "ChargePoint is not accepted" }, 400);
}
// Require the charge point to be online
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
"RemoteStartTransaction",
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
]),
);
console.log(
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
`connector=${body.connectorId} idTag=${body.idTag} user=${currentUser.id}`,
);
return c.json({ success: true });
});
/** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */
app.get("/", async (c) => {
const page = Math.max(1, Number(c.req.query("page") ?? 1));
@@ -47,10 +123,14 @@ app.get("/", async (c) => {
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
connectorNumber: connector.connectorId,
idTagUserId: idTag.userId,
idTagUserName: user.name,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.leftJoin(connector, eq(transaction.connectorId, connector.id))
.leftJoin(idTag, eq(transaction.idTag, idTag.idTag))
.leftJoin(user, eq(idTag.userId, user.id))
.where(whereClause)
.orderBy(desc(transaction.startTimestamp))
.limit(limit)
@@ -61,6 +141,8 @@ app.get("/", async (c) => {
...r.transaction,
chargePointIdentifier: r.chargePointIdentifier,
connectorNumber: r.connectorNumber,
idTagUserId: r.idTagUserId,
idTagUserName: r.idTagUserName,
energyWh:
r.transaction.stopMeterValue != null
? r.transaction.stopMeterValue - r.transaction.startMeterValue
@@ -125,7 +207,7 @@ app.post("/:id/stop", async (c) => {
if (!row) return c.json({ error: "Not found" }, 404);
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
const now = new Date();
const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
@@ -152,11 +234,11 @@ app.post("/:id/stop", async (c) => {
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now,
stopTimestamp: now.toDate(),
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
updatedAt: now,
updatedAt: now.toDate(),
})
.where(eq(transaction.id, id))
.returning();
@@ -166,7 +248,7 @@ app.post("/:id/stop", async (c) => {
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now,
updatedAt: now.toDate(),
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
@@ -197,7 +279,7 @@ app.delete("/:id", async (c) => {
if (!row.transaction.stopTimestamp) {
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, row.transaction.connectorId));
}

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { user } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -57,7 +58,7 @@ app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
.update(user)
.set({
...body,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(user.id, userId))
.returning({

134
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,134 @@
############################################################
# Production-ready .dockerignore for a Next.js (Vercel-style) app
# Keeps Docker builds fast, lean, and free of development files.
############################################################
# Dependencies (installed inside Docker, never copied)
node_modules/
.pnpm-store/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Next.js build outputs (always generated during `next build`)
.next/
out/
dist/
build/
.vercel/
# Tests and testing output (not needed in production images)
coverage/
.nyc_output/
__tests__/
__mocks__/
jest/
cypress/
cypress/screenshots/
cypress/videos/
playwright-report/
test-results/
.vitest/
vitest.config.*
jest.config.*
cypress.config.*
playwright.config.*
*.test.*
*.spec.*
# Local development and editor files
.git/
.gitignore
.gitattributes
.vscode/
.idea/
*.swp
*.swo
*~
*.log
# Environment variables (only commit template files)
.env
.env*.local
.env.development
.env.test
.env.production.local
# Docker configuration files (not needed inside build context)
Dockerfile*
.dockerignore
compose.yaml
compose.yml
docker-compose*.yaml
docker-compose*.yml
# Documentation
*.md
docs/
# CI/CD configuration files
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
Jenkinsfile
# Cache directories and temporary data
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.swc/
.turbo/
.tmp/
.temp/
# TypeScript build metadata
*.tsbuildinfo
# Sensitive or unnecessary configuration files
*.pem
.editorconfig
.prettierrc*
prettier.config.*
.eslintrc*
eslint.config.*
.stylelintrc*
stylelint.config.*
.babelrc*
*.iml
*.ipr
*.iws
# OS-specific junk
.DS_Store
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# AI/ML tool metadata and configs
.cursor/
.cursorrules
.copilot/
.copilotignore
.github/copilot/
.gemini/
.anthropic/
.kiro
.claude
AGENTS.md
.agents/
# AI-generated temp files
*.aider*
*.copilot*
*.chatgpt*
*.claude*
*.gemini*
*.openai*
*.anthropic*

59
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
FROM node:22-alpine AS base
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM base AS builder
RUN apk add --no-cache gcompat
RUN corepack enable && corepack prepare pnpm@10.18.2 --activate
WORKDIR /app
# 复制 monorepo 根配置catalog、lockfile 等)
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
# 复制 web 应用源码
COPY apps/web/package.json ./apps/web/package.json
COPY apps/web/next.config.ts ./apps/web/next.config.ts
COPY apps/web/tsconfig.json ./apps/web/tsconfig.json
COPY apps/web/postcss.config.mjs ./apps/web/postcss.config.mjs
COPY apps/web/app ./apps/web/app
COPY apps/web/components ./apps/web/components
COPY apps/web/lib ./apps/web/lib
COPY apps/web/types ./apps/web/types
COPY apps/web/public ./apps/web/public
COPY apps/web/middleware.ts ./apps/web/middleware.ts
RUN pnpm install --filter web
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter web run build
# ============================================
# Stage 2: Run Next.js application
# ============================================
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

View File

@@ -19,6 +19,7 @@ import {
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
// ── Status maps ────────────────────────────────────────────────────────────
@@ -60,8 +61,7 @@ const TX_LIMIT = 10;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
@@ -69,15 +69,7 @@ function formatDuration(start: string, stop: string | null): string {
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
const d = Math.floor(h / 24);
return `${d} 天前`;
return dayjs(iso).fromNow();
}
// ── Edit form type ─────────────────────────────────────────────────────────
@@ -155,7 +147,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval
const isOnline =
cp?.lastHeartbeatAt != null &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -343,7 +335,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastHeartbeatAt ? (
<span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastHeartbeatAt)}
</span>
) : (
@@ -355,7 +347,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastBootNotificationAt ? (
<span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastBootNotificationAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastBootNotificationAt)}
</span>
) : (
@@ -366,7 +358,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{new Date(cp.createdAt).toLocaleDateString("zh-CN")}
{dayjs(cp.createdAt).format("YYYY/M/D")}
</dd>
</div>
</dl>
@@ -437,12 +429,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
<p className="text-xs text-muted">
{" "}
{new Date(conn.lastStatusAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
@@ -489,12 +476,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell className="tabular-nums text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
</Table.Cell>
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
<Table.Cell className="tabular-nums">

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
@@ -13,12 +13,22 @@ import {
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import {
Plus,
Pencil,
PlugConnection,
TrashBin,
ArrowRotateRight,
QrCode,
} from "@gravity-ui/icons";
import { QRCodeSVG } from "qrcode.react";
import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
@@ -108,6 +118,7 @@ export default function ChargePointsPage() {
const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
const {
data: chargePoints = [],
refetch: refetchList,
@@ -183,6 +194,11 @@ export default function ChargePointsPage() {
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [qrOrigin, setQrOrigin] = useState("");
useEffect(() => {
setQrOrigin(window.location.origin);
}, []);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -319,6 +335,65 @@ export default function ChargePointsPage() {
</>
)}
{/* QR Code Modal */}
{isAdmin && (
<Modal
isOpen={qrTarget !== null}
onOpenChange={(open) => {
if (!open) setQrTarget(null);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{qrTarget?.chargePointIdentifier} </Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-muted">
</p>
{qrTarget &&
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
<p className="text-sm text-muted">
线
</p>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
{qrTarget?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => {
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
return (
<div
key={conn.id}
className="flex flex-col items-center gap-2 rounded-xl border border-border p-3"
>
<p className="text-xs font-medium text-foreground">
#{conn.connectorId}
</p>
<QRCodeSVG value={url} size={120} className="rounded" />
<p className="break-all text-center font-mono text-[9px] text-muted leading-tight">
{url}
</p>
</div>
);
})}
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button variant="ghost" onPress={() => setQrTarget(null)}>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
@@ -350,12 +425,33 @@ export default function ChargePointsPage() {
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell>
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
</Link>
<Tooltip delay={0}>
<Tooltip.Trigger>
<div className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
}`}
/>
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
</Link>
</div>
</Tooltip.Trigger>
<Tooltip.Content placement="start">
{cp.lastHeartbeatAt
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
{isAdmin && (
<Table.Cell>
@@ -400,7 +496,7 @@ export default function ChargePointsPage() {
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
) : (
<span className="text-muted"></span>
)}
@@ -435,6 +531,15 @@ export default function ChargePointsPage() {
>
<Pencil className="size-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => setQrTarget(cp)}
aria-label="查看二维码"
>
<QrCode className="size-4" />
</Button>
<Modal>
<Button
isIconOnly

View File

@@ -0,0 +1,628 @@
"use client";
import { useState, useEffect, useRef, Fragment } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button, Chip, Modal, Spinner } from "@heroui/react";
import {
ThunderboltFill,
PlugConnection,
CreditCard,
Check,
QrCode,
Xmark,
} from "@gravity-ui/icons";
import { api } from "@/lib/api";
import dayjs from "@/lib/dayjs";
// ── Status maps (same as charge-points page) ────────────────────────────────
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
Unavailable: "不可用",
Occupied: "占用",
};
const statusDotClass: Record<string, string> = {
Available: "bg-success",
Charging: "bg-accent animate-pulse",
Preparing: "bg-warning animate-pulse",
Finishing: "bg-warning",
SuspendedEV: "bg-warning",
SuspendedEVSE: "bg-warning",
Reserved: "bg-warning",
Faulted: "bg-danger",
Unavailable: "bg-danger",
Occupied: "bg-warning",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-start">
{labels.map((label, i) => {
const idx = i + 1;
const isActive = step === idx;
const isDone = idx < step;
const isLast = i === labels.length - 1;
return (
<Fragment key={i}>
<button
type="button"
onClick={() => isDone && onGoBack(idx)}
disabled={!isDone}
className={[
"flex shrink-0 flex-col items-center gap-2",
isDone ? "cursor-pointer" : "cursor-default",
].join(" ")}
>
<span
className={[
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 ring-offset-2 ring-offset-background transition-all",
isActive
? "bg-accent text-accent-foreground ring-accent"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-3.5" /> : idx}
</span>
<span
className={[
"text-[11px] font-medium leading-none whitespace-nowrap",
isActive ? "text-accent" : isDone ? "text-foreground" : "text-muted",
].join(" ")}
>
{label}
</span>
</button>
{!isLast && (
<div className="flex-1 pt-3.5">
<span
className={[
"block h-px w-full transition-colors",
isDone ? "bg-success" : "bg-border",
].join(" ")}
/>
</div>
)}
</Fragment>
);
})}
</div>
);
}
// ── QR Scanner ───────────────────────────────────────────────────────────────
type ScannerProps = {
onResult: (raw: string) => void;
onClose: () => void;
};
function QrScanner({ onResult, onClose }: ScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scanningRef = useRef(true);
const mountedRef = useRef(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
mountedRef.current = true;
scanningRef.current = true;
let detector: any = null;
async function start() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
} catch (err: any) {
if (mountedRef.current) setError("无法访问摄像头:" + (err?.message ?? "未知错误"));
return;
}
if (!mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch (err: any) {
// AbortError fires when the element is removed mid-play (e.g. Modal animation).
// It is not a real error — just bail out silently.
if (err?.name === "AbortError") return;
if (mountedRef.current) setError("无法播放摄像头画面:" + (err?.message ?? "未知错误"));
return;
}
}
if (!mountedRef.current) return;
if (!("BarcodeDetector" in window)) {
if (mountedRef.current) setError("当前浏览器不支持实时扫描,请升级至最新版本");
return;
}
detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] });
const scan = async () => {
if (!scanningRef.current || !videoRef.current) return;
try {
const codes: Array<{ rawValue: string }> = await detector.detect(videoRef.current);
if (codes.length > 0) {
onResult(codes[0].rawValue);
return;
}
} catch {}
requestAnimationFrame(scan);
};
requestAnimationFrame(scan);
}
start();
return () => {
mountedRef.current = false;
scanningRef.current = false;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
}, [onResult]);
return (
<div className="relative h-full w-full overflow-hidden bg-black">
{error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-danger">{error}</p>
</div>
) : (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
{/* Aim overlay */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="size-56 rounded-2xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]" />
</div>
<p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/80">
</p>
</>
)}
{/* Close button — overlaid top-right */}
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 flex size-9 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm hover:bg-black/70"
aria-label="关闭扫描"
>
<Xmark className="size-5" />
</button>
</div>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function ChargePage() {
const searchParams = useSearchParams();
const [step, setStep] = useState(1);
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
const [selectedConnectorId, setSelectedConnectorId] = useState<number | null>(null);
const [selectedIdTag, setSelectedIdTag] = useState<string | null>(null);
const [showScanner, setShowScanner] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
const [startError, setStartError] = useState<string | null>(null);
// Detect mobile
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent));
}, []);
// Pre-fill from URL params (QR code redirect)
useEffect(() => {
const cpId = searchParams.get("cpId");
const connector = searchParams.get("connector");
if (cpId) {
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setStep(2);
}
}
}, [searchParams]);
const { data: chargePoints = [], isLoading: cpLoading } = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 5_000,
});
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
queryKey: ["idTags"],
queryFn: () => api.idTags.list().catch(() => []),
});
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
const myTags = idTags?.filter((t) => t.status === "Accepted") ?? [];
const startMutation = useMutation({
mutationFn: async () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) {
throw new Error("请先完成所有选择");
}
return api.transactions.remoteStart({
chargePointIdentifier: selectedCp.chargePointIdentifier,
connectorId: selectedConnectorId,
idTag: selectedIdTag,
});
},
onSuccess: () => {
setStartResult("success");
},
onError: (err: Error) => {
setStartResult("error");
const msg = err.message ?? "";
if (msg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
else if (msg.includes("not accepted")) setStartError("充电桩未启用,请联系管理员");
else if (msg.includes("idTag")) setStartError("储值卡无效或无权使用");
else setStartError("启动失败:" + msg);
},
});
function resetAll() {
setStep(1);
setSelectedCpId(null);
setSelectedConnectorId(null);
setSelectedIdTag(null);
setStartResult(null);
setStartError(null);
}
function handleScanResult(raw: string) {
setShowScanner(false);
setScanError(null);
try {
// Support both helios:// scheme and https:// web URLs
const url = new URL(raw);
const cpId = url.searchParams.get("cpId");
const connector = url.searchParams.get("connector");
if (!cpId) {
setScanError("二维码内容无效,缺少充电桩信息");
return;
}
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setSelectedConnectorId(null);
setStep(2);
}
} catch {
setScanError("二维码内容格式错误");
}
}
// ── Success screen ─────────────────────────────────────────────────────────
if (startResult === "success") {
return (
<div className="flex flex-col items-center justify-center gap-6 py-20 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10">
<Check className="size-8 text-success" />
</div>
<div>
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="mt-1.5 text-sm text-muted">
<br />
"充电记录"
</p>
</div>
<Button onPress={resetAll}></Button>
</div>
);
}
// ── Main UI ────────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
{/* QR scan button — mobile only */}
{isMobile && (
<Button
size="sm"
variant="secondary"
onPress={() => {
setScanError(null);
setShowScanner(true);
}}
isDisabled={showScanner}
>
<QrCode className="size-4" />
</Button>
)}
</div>
{/* QR Scanner Modal */}
<Modal
isOpen={showScanner}
onOpenChange={(open) => {
if (!open) setShowScanner(false);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside" size="full">
<Modal.Dialog className="h-full overflow-hidden p-0">
<QrScanner onResult={handleScanResult} onClose={() => setShowScanner(false)} />
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{scanError && <p className="text-sm text-danger">{scanError}</p>}
{/* Step bar */}
<StepBar step={step} onGoBack={(t) => setStep(t)} />
{/* ── Step 1: Select charge point ──────────────────────────────── */}
{step === 1 && (
<div className="space-y-3">
{cpLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const availableCount = cp.connectors.filter(
(c) => c.status === "Available",
).length;
const disabled = !online || availableCount === 0;
return (
<button
key={cp.id}
type="button"
disabled={disabled}
onClick={() => {
setSelectedCpId(cp.id);
setSelectedConnectorId(null);
setStep(2);
}}
className={[
"flex flex-col gap-2.5 rounded-xl border p-4 text-left transition-all",
disabled
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedCpId === cp.id ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground truncate">
{cp.chargePointIdentifier}
</span>
<Chip size="sm" color={online ? "success" : "default"} variant="soft">
{online ? "在线" : "离线"}
</Chip>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-xs text-muted">
<PlugConnection className="size-3" />
{availableCount}/{cp.connectors.length}
</span>
{cp.feePerKwh > 0 && (
<span className="text-xs text-muted">
· ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh
</span>
)}
{cp.feePerKwh === 0 && <span className="text-xs text-success">· </span>}
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-3">
{selectedCp && (
<p className="text-sm text-muted">
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</p>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{selectedCp?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => {
const available = conn.status === "Available";
return (
<button
key={conn.id}
type="button"
disabled={!available}
onClick={() => {
if (available) {
setSelectedConnectorId(conn.connectorId);
setStep(3);
}
}}
className={[
"flex flex-col gap-2 rounded-xl border p-4 text-left transition-all",
!available
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedConnectorId === conn.connectorId
? "border-accent bg-accent/10"
: "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">
#{conn.connectorId}
</span>
<span
className={`size-2 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
/>
</div>
<span className="text-xs text-muted">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
{step === 3 && (
<div className="space-y-5">
<div className="flex flex-wrap gap-3 text-sm text-muted">
{selectedCp && (
<span>
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</span>
)}
{selectedConnectorId !== null && (
<span>
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
</span>
)}
</div>
{tagsLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : myTags.length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<CreditCard className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<p className="mt-1 text-xs text-muted">"储值卡"</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => (
<button
key={tag.idTag}
type="button"
onClick={() => setSelectedIdTag(tag.idTag)}
className={[
"flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all cursor-pointer",
"border-border hover:border-accent hover:bg-accent/5",
selectedIdTag === tag.idTag ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium text-foreground">
{tag.idTag}
</span>
{selectedIdTag === tag.idTag && (
<Check className="size-4 shrink-0 text-accent" />
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted"></span>
<span className="text-xs font-medium text-foreground">
¥{(tag.balance / 100).toFixed(2)}
</span>
</div>
</button>
))}
</div>
)}
{startResult === "error" && (
<p className="text-sm text-danger">{startError ?? "启动失败,请重试"}</p>
)}
<div className="flex gap-3">
<Button
variant="secondary"
onPress={() => {
setStep(2);
setStartResult(null);
setStartError(null);
}}
>
</Button>
<Button
isDisabled={!selectedIdTag || startMutation.isPending}
onPress={() => startMutation.mutate()}
>
{startMutation.isPending ? (
<Spinner size="sm" />
) : (
<ThunderboltFill className="size-4" />
)}
{startMutation.isPending ? "发送中…" : "启动充电"}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -26,6 +26,7 @@ import { parseDate } from "@internationalized/date";
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
@@ -529,7 +530,7 @@ export default function IdTagsPage() {
)}
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
@@ -538,7 +539,7 @@ export default function IdTagsPage() {
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Spinner } from "@heroui/react";
import Link from "next/link";
@@ -13,23 +14,29 @@ import {
ArrowRotateRight,
TriangleExclamation,
} from "@gravity-ui/icons";
import { AreaChart } from "@tremor/react";
import dayjs from "@/lib/dayjs";
import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
import {
api,
type Stats,
type UserStats,
type Transaction,
type ChargePoint,
type ChartRange,
type ChartDataPoint,
} from "@/lib/api";
// ── Helpers ────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
const ms = Date.now() - new Date(dateStr).getTime();
if (ms < 60_000) return "刚刚";
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} 分钟前`;
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} 小时前`;
return new Date(dateStr).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
return dayjs(dateStr).fromNow();
}
function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false;
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
}
// ── StatCard ───────────────────────────────────────────────────────────────
@@ -98,6 +105,188 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
);
}
// ── TrendChart ─────────────────────────────────────────────────────────────
function formatBucket(bucket: string, range: ChartRange): string {
const d = dayjs(bucket);
return range === "24h" ? d.format("HH:mm") : d.format("MM/DD");
}
const RANGES: { label: string; value: ChartRange }[] = [
{ label: "24 小时", value: "24h" },
{ label: "7 天", value: "7d" },
{ label: "30 天", value: "30d" },
];
type ChartView = "economy" | "activity";
const SERIES_CONFIG = {
revenue: {
color: "sky" as const,
label: "营收",
unit: "¥",
fmt: (v: number) => (v === 0 ? "¥0" : `¥${v.toFixed(v < 10 ? 2 : 1)}`),
},
energyKwh: {
color: "emerald" as const,
label: "充电量",
unit: "kWh",
fmt: (v: number) => (v === 0 ? "0 kWh" : `${v.toFixed(v < 10 ? 2 : 1)} kWh`),
},
transactions: {
color: "violet" as const,
label: "充电次数",
unit: "次",
fmt: (v: number) => `${v}`,
},
utilizationPct: {
color: "amber" as const,
label: "利用率",
unit: "%",
fmt: (v: number) => `${v.toFixed(1)}%`,
},
} as const;
const VIEW_SERIES: Record<ChartView, (keyof typeof SERIES_CONFIG)[]> = {
economy: ["revenue", "energyKwh"],
activity: ["transactions", "utilizationPct"],
};
function TrendChart() {
const [range, setRange] = useState<ChartRange>("7d");
const [view, setView] = useState<ChartView>("economy");
const [hidden, setHidden] = useState<Set<string>>(new Set());
const toggle = (key: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const switchView = (v: ChartView) => {
setView(v);
setHidden(new Set());
};
const allSeriesForView = VIEW_SERIES[view];
const visibleSeries = allSeriesForView.filter((k) => !hidden.has(k));
const visibleColors = visibleSeries.map((k) => SERIES_CONFIG[k].color);
const { data, isPending } = useQuery<ChartDataPoint[]>({
queryKey: ["stats-chart", range],
queryFn: () => api.stats.chart(range),
staleTime: 60_000,
});
return (
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-3.5">
{/* View toggle */}
<div className="flex gap-1">
{(["economy", "activity"] as ChartView[]).map((v) => (
<button
key={v}
onClick={() => switchView(v)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
view === v
? "bg-accent-soft text-accent"
: "text-muted hover:bg-default hover:text-foreground"
}`}
>
{v === "economy" ? "营收 & 电量" : "次数 & 利用率"}
</button>
))}
</div>
{/* Range toggle */}
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.value}
onClick={() => setRange(r.value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
range === r.value
? "bg-accent text-white"
: "text-muted hover:bg-default hover:text-foreground"
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Chart */}
<div className="px-4 py-4">
{isPending ? (
<div className="flex h-56 items-center justify-center">
<Spinner />
</div>
) : (
<AreaChart
data={(data ?? []).map((p) => ({ ...p, label: formatBucket(p.bucket, range) }))}
index="label"
categories={visibleSeries}
colors={visibleColors}
valueFormatter={(v) => `${v}`}
customTooltip={(props) => {
if (!props.active || !props.payload?.length) return null;
return (
<div className="rounded-lg border border-border bg-overlay px-3 py-2 shadow-md text-xs">
<p className="mb-1.5 font-semibold text-foreground">{props.label}</p>
{props.payload.map((entry) => {
const key = entry.dataKey as string;
const cfg = SERIES_CONFIG[key as keyof typeof SERIES_CONFIG];
if (!cfg) return null;
return (
<div key={key} className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
<span className="text-muted">{cfg.label}</span>
<span className="ml-auto font-medium text-foreground">
{cfg.fmt(entry.value as number)}
</span>
</div>
);
})}
</div>
);
}}
showLegend={false}
showGridLines={true}
showYAxis={true}
showXAxis={true}
curveType="monotone"
showAnimation
className="h-56"
/>
)}
{/* Legend */}
<div className="mt-2 flex items-center gap-2 px-1">
{allSeriesForView.map((key) => {
const cfg = SERIES_CONFIG[key];
return (
<button
key={key}
onClick={() => toggle(key)}
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs transition-opacity select-none hover:opacity-100 ${
hidden.has(key) ? "opacity-35" : "opacity-100"
}`}
>
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
<span className="text-muted">
{cfg.label}{cfg.unit}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}
// ── RecentTransactions ────────────────────────────────────────────────────
function RecentTransactions({ txns }: { txns: Transaction[] }) {
@@ -130,10 +319,22 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
)}
</p>
<p className="text-xs text-muted">{tx.idTag}</p>
<p className="text-xs text-muted">
{tx.idTag}
{tx.idTagUserId && (
<>
<span className="mx-1">·</span>
{tx.idTagUserName ?? tx.idTagUserId}
<span className="ml-1 opacity-50">({tx.idTagUserId.slice(0, 8)})</span>
</>
)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-medium tabular-nums text-foreground">{kwh}</p>
<p className="text-sm font-medium tabular-nums text-foreground">
{kwh}
<span className="text-xs"> kWh</span>
</p>
<p className="text-xs text-muted">{amount}</p>
</div>
<div className="w-16 shrink-0 text-right">
@@ -357,6 +558,9 @@ export default function DashboardPage() {
/>
</div>
{/* Trend chart */}
{isAdmin && <TrendChart />}
{/* Detail panels */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="lg:col-span-2">

View File

@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField } from "@heroui/react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
import { authClient, useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type Passkey = {
id: string;
@@ -18,8 +19,6 @@ export default function SettingsPage() {
// ── Profile ──────────────────────────────────────────────────────────────
const [profileName, setProfileName] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
// sync name from session once loaded
useEffect(() => {
@@ -27,19 +26,17 @@ export default function SettingsPage() {
}, [session?.user.name]);
const handleSaveProfile = async () => {
setProfileError("");
setProfileSuccess("");
setSavingProfile(true);
try {
const res = await authClient.updateUser({ name: profileName.trim() });
if (res?.error) {
setProfileError(res.error.message ?? "保存失败");
toast.danger(res.error.message ?? "保存失败");
} else {
setProfileSuccess("显示名称已更新");
toast.success("显示名称已更新");
await refetchSession();
}
} catch {
setProfileError("保存失败,请重试");
toast.danger("保存失败,请重试");
} finally {
setSavingProfile(false);
}
@@ -95,8 +92,6 @@ export default function SettingsPage() {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [renameSaving, setRenameSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
const loadPasskeys = useCallback(async () => {
@@ -105,7 +100,7 @@ export default function SettingsPage() {
const res = await authClient.passkey.listUserPasskeys();
setPasskeys((res.data as Passkey[] | null) ?? []);
} catch {
setError("获取 Passkey 列表失败");
toast.danger("获取 Passkey 列表失败");
} finally {
setLoading(false);
}
@@ -117,48 +112,42 @@ export default function SettingsPage() {
const handleStartAdd = () => {
setAddingName("");
setError("");
setSuccess("");
};
const handleCancelAdd = () => setAddingName(null);
const handleRegister = async () => {
setError("");
setSuccess("");
setRegistering(true);
try {
const res = await authClient.passkey.addPasskey({
name: addingName?.trim() || undefined,
});
if (res?.error) {
setError(res.error.message ?? "注册 Passkey 失败");
toast.danger(res.error.message ?? "注册 Passkey 失败");
} else {
setSuccess("Passkey 注册成功");
toast.success("Passkey 注册成功");
setAddingName(null);
await loadPasskeys();
}
} catch {
setError("注册 Passkey 失败,请重试");
toast.danger("注册 Passkey 失败,请重试");
} finally {
setRegistering(false);
}
};
const handleDelete = async (id: string) => {
setError("");
setSuccess("");
setDeletingId(id);
try {
const res = await authClient.passkey.deletePasskey({ id });
if (res?.error) {
setError(res.error.message ?? "删除 Passkey 失败");
toast.danger(res.error.message ?? "删除 Passkey 失败");
} else {
setPasskeys((prev) => prev.filter((p) => p.id !== id));
setSuccess("Passkey 已删除");
toast.success("Passkey 已删除");
}
} catch {
setError("删除 Passkey 失败,请重试");
toast.danger("删除 Passkey 失败,请重试");
} finally {
setDeletingId(null);
}
@@ -176,7 +165,6 @@ export default function SettingsPage() {
};
const handleRename = async (id: string) => {
setError("");
setRenameSaving(true);
try {
const res = await (authClient.passkey as any).updatePasskey({
@@ -184,7 +172,7 @@ export default function SettingsPage() {
name: renameValue.trim() || undefined,
});
if (res?.error) {
setError(res.error.message ?? "重命名失败");
toast.danger(res.error.message ?? "重命名失败");
} else {
setPasskeys((prev) =>
prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() || null } : p)),
@@ -192,7 +180,7 @@ export default function SettingsPage() {
setRenamingId(null);
}
} catch {
setError("重命名失败,请重试");
toast.danger("重命名失败,请重试");
} finally {
setRenameSaving(false);
}
@@ -228,24 +216,6 @@ export default function SettingsPage() {
}}
/>
</TextField>
{profileError && (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileError("")} />
</Alert>
)}
{profileSuccess && (
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{profileSuccess}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileSuccess("")} />
</Alert>
)}
<div className="flex justify-end">
<Button
size="sm"
@@ -379,29 +349,6 @@ export default function SettingsPage() {
</div>
)}
{error && (
<div className="border-b border-border px-5 py-3">
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setError("")} />
</Alert>
</div>
)}
{success && (
<div className="border-b border-border px-5 py-3">
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{success}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setSuccess("")} />
</Alert>
</div>
)}
{loading ? (
<div className="flex justify-center py-8">
<Spinner />
@@ -465,11 +412,7 @@ export default function SettingsPage() {
{/* Date row: always visible */}
<p className="text-xs text-muted">
{" "}
{new Date(pk.createdAt).toLocaleString("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
})}
{dayjs(pk.createdAt).format("YYYY年M月D日")}
</p>
</div>
</div>

View File

@@ -6,13 +6,13 @@ import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const LIMIT = 15;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
@@ -78,23 +78,32 @@ export default function TransactionsPage() {
<p className="mt-0.5 text-sm text-muted"> {data?.total ?? "—"} </p>
</div>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))} </div> </div>
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))}{" "}
</div>{" "}
</div>
</div>
<Table>
@@ -138,7 +147,7 @@ export default function TransactionsPage() {
)}
</Table.Cell>
<Table.Cell className="whitespace-nowrap text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
{dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell className="text-sm">
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}

View File

@@ -20,6 +20,7 @@ import {
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type CreateForm = {
name: string;
@@ -452,7 +453,7 @@ export default function UsersPage() {
)}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(u.createdAt).toLocaleString("zh-CN")}
{dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">

View File

@@ -1,5 +1,8 @@
@import "tailwindcss";
@import "@heroui/styles";
@source inline("{bg,text,border,ring,stroke,fill}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("hover:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("data-[selected]:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
/*
* HeroUI Theme Customization
@@ -17,26 +20,26 @@
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0069 253.83);
--border: oklch(90.00% 0.0069 253.83);
--danger: oklch(65.32% 0.2360 25.74);
--border: oklch(90% 0.0069 253.83);
--danger: oklch(65.32% 0.236 25.74);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0069 253.83);
--default: oklch(94% 0.0069 253.83);
--default-foreground: oklch(21.03% 0.0059 253.83);
--field-background: oklch(100.00% 0.0034 253.83);
--field-background: oklch(100% 0.0034 253.83);
--field-foreground: oklch(21.03% 0.0069 253.83);
--field-placeholder: oklch(55.17% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(21.03% 0.0069 253.83);
--muted: oklch(55.17% 0.0138 253.83);
--overlay: oklch(100.00% 0.0021 253.83);
--overlay: oklch(100% 0.0021 253.83);
--overlay-foreground: oklch(21.03% 0.0069 253.83);
--scrollbar: oklch(87.10% 0.0069 253.83);
--segment: oklch(100.00% 0.0069 253.83);
--scrollbar: oklch(87.1% 0.0069 253.83);
--segment: oklch(100% 0.0069 253.83);
--segment-foreground: oklch(21.03% 0.0069 253.83);
--separator: oklch(92.00% 0.0069 253.83);
--separator: oklch(92% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(100.00% 0.0034 253.83);
--surface: oklch(100% 0.0034 253.83);
--surface-foreground: oklch(21.03% 0.0069 253.83);
--surface-secondary: oklch(95.24% 0.0055 253.83);
--surface-secondary-foreground: oklch(21.03% 0.0069 253.83);
@@ -60,29 +63,29 @@
/* Theme Colors (Dark Mode) */
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0069 253.83);
--border: oklch(28.00% 0.0069 253.83);
--danger: oklch(59.40% 0.1994 24.63);
--background: oklch(12% 0.0069 253.83);
--border: oklch(28% 0.0069 253.83);
--danger: oklch(59.4% 0.1994 24.63);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0069 253.83);
--default: oklch(27.4% 0.0069 253.83);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0138 253.83);
--field-foreground: oklch(99.11% 0.0069 253.83);
--field-placeholder: oklch(70.50% 0.0138 253.83);
--field-placeholder: oklch(70.5% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(99.11% 0.0069 253.83);
--muted: oklch(70.50% 0.0138 253.83);
--muted: oklch(70.5% 0.0138 253.83);
--overlay: oklch(21.03% 0.0138 253.83);
--overlay-foreground: oklch(99.11% 0.0069 253.83);
--scrollbar: oklch(70.50% 0.0069 253.83);
--scrollbar: oklch(70.5% 0.0069 253.83);
--segment: oklch(39.64% 0.0069 253.83);
--segment-foreground: oklch(99.11% 0.0069 253.83);
--separator: oklch(25.00% 0.0069 253.83);
--separator: oklch(25% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(21.03% 0.0138 253.83);
--surface-foreground: oklch(99.11% 0.0069 253.83);
--surface-secondary: oklch(25.70% 0.0103 253.83);
--surface-secondary: oklch(25.7% 0.0103 253.83);
--surface-secondary-foreground: oklch(99.11% 0.0069 253.83);
--surface-tertiary: oklch(27.21% 0.0103 253.83);
--surface-tertiary-foreground: oklch(99.11% 0.0069 253.83);

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist_Mono, Noto_Sans, Saira } from "next/font/google";
import { Toast } from "@heroui/react";
import "./globals.css";
const fontSaira = Saira({
@@ -33,6 +34,7 @@ export default function RootLayout({
<body
className={`${fontSaira.variable} ${fontNotoSans.variable} ${fontMono.variable} antialiased`}
>
<Toast.Provider />
{children}
</body>
</html>

View File

@@ -1,12 +1,12 @@
"use client";
import { useState } from "react";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Fingerprint, Thunderbolt } from "@gravity-ui/icons";
import { authClient } from "@/lib/auth-client";
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const justSetup = searchParams.get("setup") === "1";
@@ -145,3 +145,11 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}

View File

@@ -4,8 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Thunderbolt } from "@gravity-ui/icons";
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
import { api } from "@/lib/api";
export default function SetupPage() {
const router = useRouter();
@@ -38,28 +37,15 @@ export default function SetupPage() {
setLoading(true);
try {
const res = await fetch(`${CSMS_URL}/api/setup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: form.name,
email: form.email,
username: form.username,
password: form.password,
}),
await api.setup.create({
name: form.name,
email: form.email,
username: form.username,
password: form.password,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? "初始化失败,请重试");
return;
}
// 初始化成功,跳转登录页
router.push("/login?setup=1");
} catch {
setError("网络错误,请稍后重试");
} catch (err) {
setError(err instanceof Error ? err.message : "初始化失败,请重试");
} finally {
setLoading(false);
}
@@ -74,7 +60,7 @@ export default function SetupPage() {
</div>
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
<p className="mt-1 text-sm text-muted"> · </p>
<p className="mt-1 text-sm text-muted"> · </p>
</div>
</div>

View File

@@ -1,12 +1,67 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@heroui/react";
import { signOut, useSession } from "@/lib/auth-client";
import { APIError } from "@/lib/api";
/** 监听 better-auth 会话变为 null服务端过期/异地登出等场景)*/
function SessionMonitor({ onExpired }: { onExpired: () => void }) {
const { data: session, isPending } = useSession();
const wasLoggedIn = useRef(false);
useEffect(() => {
if (isPending) return;
if (session) {
wasLoggedIn.current = true;
} else if (wasLoggedIn.current) {
wasLoggedIn.current = false;
onExpired();
}
}, [session, isPending, onExpired]);
return null;
}
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const isHandling = useRef(false);
const handleExpired = useCallback(async () => {
if (isHandling.current) return;
isHandling.current = true;
try {
await signOut({ fetchOptions: { credentials: "include" } });
} catch {
// ignore sign-out errors
}
toast.warning("登录已过期,请重新登录");
router.push("/login");
}, [router]);
// Stable ref so the QueryClient (created once) always calls the latest handler
const handleExpiredRef = useRef(handleExpired);
handleExpiredRef.current = handleExpired;
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof APIError && error.status === 401) {
handleExpiredRef.current();
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (error instanceof APIError && error.status === 401) {
handleExpiredRef.current();
}
},
}),
defaultOptions: {
queries: {
staleTime: 10_000,
@@ -14,5 +69,11 @@ export function ReactQueryProvider({ children }: { children: React.ReactNode })
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<SessionMonitor onExpired={handleExpired} />
{children}
</QueryClientProvider>
);
}

View File

@@ -10,12 +10,17 @@ import {
Person,
PlugConnection,
Thunderbolt,
ThunderboltFill,
Xmark,
Bars,
} from "@gravity-ui/icons";
import SidebarFooter from "@/components/sidebar-footer";
import { useSession } from "@/lib/auth-client";
const chargeItems = [
{ href: "/dashboard/charge", label: "立即充电", icon: ThunderboltFill, adminOnly: false },
];
const navItems = [
{ href: "/dashboard", label: "概览", icon: Thunderbolt, exact: true, adminOnly: false },
{ href: "/dashboard/charge-points", label: "充电桩", icon: PlugConnection, adminOnly: false },
@@ -49,7 +54,32 @@ function NavContent({
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
{/* Primary: Charge */}
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{chargeItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={[
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent"
: "text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" />
<span>{item.label}</span>
{isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
</Link>
);
})}
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{navItems

View File

@@ -1,3 +1,13 @@
export class APIError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "APIError";
}
}
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -11,7 +21,7 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`API ${path} failed (${res.status}): ${text}`);
throw new APIError(res.status, `API ${path} failed (${res.status}): ${text}`);
}
return res.json() as Promise<T>;
}
@@ -101,6 +111,8 @@ export type Transaction = {
connectorNumber: number | null;
idTag: string;
idTagStatus: string | null;
idTagUserId: string | null;
idTagUserName: string | null;
startTimestamp: string;
stopTimestamp: string | null;
startMeterValue: number | null;
@@ -142,9 +154,21 @@ export type PaginatedTransactions = {
// ── API functions ──────────────────────────────────────────────────────────
export type ChartRange = "30d" | "7d" | "24h";
export type ChartDataPoint = {
bucket: string;
energyKwh: number;
revenue: number;
transactions: number;
utilizationPct: number;
};
export const api = {
stats: {
get: () => apiFetch<Stats | UserStats>("/api/stats"),
chart: (range: ChartRange) =>
apiFetch<ChartDataPoint[]>(`/api/stats/chart?range=${range}`),
},
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
@@ -196,6 +220,11 @@ export const api = {
apiFetch<Transaction & { online: boolean }>(`/api/transactions/${id}/stop`, {
method: "POST",
}),
remoteStart: (data: { chargePointIdentifier: string; connectorId: number; idTag: string }) =>
apiFetch<{ success: true }>("/api/transactions/remote-start", {
method: "POST",
body: JSON.stringify(data),
}),
delete: (id: number) =>
apiFetch<{ success: true }>(`/api/transactions/${id}`, { method: "DELETE" }),
},
@@ -248,4 +277,8 @@ export const api = {
},
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
},
setup: {
create: (data: { name: string; email: string; username: string; password: string }) =>
apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }),
},
};

8
apps/web/lib/dayjs.ts Normal file
View File

@@ -0,0 +1,8 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/zh-cn";
dayjs.extend(relativeTime);
dayjs.locale("zh-cn");
export default dayjs;

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -13,8 +13,12 @@
"@heroui/styles": "3.0.0-beta.8",
"@internationalized/date": "^3.12.0",
"@tanstack/react-query": "catalog:",
"@tremor/react": "4.0.0-beta-tremor-v4.4",
"@types/qrcode": "^1.5.6",
"better-auth": "catalog:",
"dayjs": "catalog:",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3"
},

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
name: helios-evcs
services:
csms:
container_name: helios-csms
build:
context: .
dockerfile: apps/csms/Dockerfile
ports:
- "3001:3001"
env_file:
- apps/csms/.env
web:
container_name: helios-web
build:
context: .
dockerfile: apps/web/Dockerfile
ports:
- "3000:3000"
env_file:
- apps/web/.env

View File

@@ -5,7 +5,7 @@
"description": "Helios EV Charging Station Management System",
"scripts": {
"dev:csms": "pnpm --filter csms dev",
"build:csms": "pnpm --filter csms build",
"build:csms": "pnpm --filter csms build:prod",
"start:csms": "pnpm --filter csms start",
"dev:web": "pnpm --filter web dev",
"build:web": "pnpm --filter web build",

View File

@@ -6,6 +6,7 @@ catalog:
"@tanstack/react-query": ^5.90.21
"@better-auth/passkey": "^1.5.4"
"better-auth": "^1.5.4"
"dayjs": "^1.11.19"
onlyBuiltDependencies:
- sharp