feat: 添加管理端前端 (HMS 基座 React 管理面板)
- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript) - 管理端自动代理 API 到 localhost:3000 (vite.config.ts) - 更新 scripts/dev.sh 支持三端启动: backend/admin/app - 登录验证通过, 用户管理/角色权限/审计日志等页面正常 - 添加 .gitignore 排除 node_modules/dist
This commit is contained in:
9
apps/web/src/utils/dayjs.ts
Normal file
9
apps/web/src/utils/dayjs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export { dayjs };
|
||||
export default dayjs;
|
||||
138
apps/web/src/utils/exprEvaluator.test.ts
Normal file
138
apps/web/src/utils/exprEvaluator.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseExpr, evaluateExpr, evaluateVisibleWhen } from './exprEvaluator'
|
||||
|
||||
describe('parseExpr', () => {
|
||||
it('parses equality expression', () => {
|
||||
const ast = parseExpr("status == 'active'")
|
||||
expect(ast).toEqual({
|
||||
type: 'eq',
|
||||
field: 'status',
|
||||
value: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses inequality expression', () => {
|
||||
const ast = parseExpr("type != 'internal'")
|
||||
expect(ast).toEqual({
|
||||
type: 'neq',
|
||||
field: 'type',
|
||||
value: 'internal',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses AND expression', () => {
|
||||
const ast = parseExpr("status == 'active' AND role == 'admin'")
|
||||
expect(ast).toEqual({
|
||||
type: 'and',
|
||||
left: { type: 'eq', field: 'status', value: 'active' },
|
||||
right: { type: 'eq', field: 'role', value: 'admin' },
|
||||
})
|
||||
})
|
||||
|
||||
it('parses OR expression', () => {
|
||||
const ast = parseExpr("status == 'active' OR status == 'pending'")
|
||||
expect(ast).toEqual({
|
||||
type: 'or',
|
||||
left: { type: 'eq', field: 'status', value: 'active' },
|
||||
right: { type: 'eq', field: 'status', value: 'pending' },
|
||||
})
|
||||
})
|
||||
|
||||
it('parses NOT expression', () => {
|
||||
const ast = parseExpr("NOT status == 'deleted'")
|
||||
expect(ast).toEqual({
|
||||
type: 'not',
|
||||
operand: { type: 'eq', field: 'status', value: 'deleted' },
|
||||
})
|
||||
})
|
||||
|
||||
it('parses parenthesized expression', () => {
|
||||
const ast = parseExpr("(status == 'a' OR status == 'b') AND role == 'admin'")
|
||||
expect(ast?.type).toBe('and')
|
||||
expect(ast?.left?.type).toBe('or')
|
||||
})
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseExpr('')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses && and || operators', () => {
|
||||
const ast = parseExpr("a == '1' && b == '2' || c == '3'")
|
||||
expect(ast).toBeDefined()
|
||||
expect(ast?.type).toBe('or')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateExpr', () => {
|
||||
it('evaluates equality true', () => {
|
||||
const ast = parseExpr("status == 'active'")!
|
||||
expect(evaluateExpr(ast, { status: 'active' })).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates equality false', () => {
|
||||
const ast = parseExpr("status == 'active'")!
|
||||
expect(evaluateExpr(ast, { status: 'inactive' })).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates inequality', () => {
|
||||
const ast = parseExpr("status != 'deleted'")!
|
||||
expect(evaluateExpr(ast, { status: 'active' })).toBe(true)
|
||||
expect(evaluateExpr(ast, { status: 'deleted' })).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates AND', () => {
|
||||
const ast = parseExpr("a == '1' AND b == '2'")!
|
||||
expect(evaluateExpr(ast, { a: '1', b: '2' })).toBe(true)
|
||||
expect(evaluateExpr(ast, { a: '1', b: '3' })).toBe(false)
|
||||
expect(evaluateExpr(ast, { a: '0', b: '2' })).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates OR', () => {
|
||||
const ast = parseExpr("a == '1' OR b == '2'")!
|
||||
expect(evaluateExpr(ast, { a: '1', b: 'x' })).toBe(true)
|
||||
expect(evaluateExpr(ast, { a: 'x', b: '2' })).toBe(true)
|
||||
expect(evaluateExpr(ast, { a: 'x', b: 'x' })).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates NOT', () => {
|
||||
const ast = parseExpr("NOT a == '1'")!
|
||||
expect(evaluateExpr(ast, { a: '1' })).toBe(false)
|
||||
expect(evaluateExpr(ast, { a: '2' })).toBe(true)
|
||||
})
|
||||
|
||||
it('handles missing field as empty string', () => {
|
||||
const ast = parseExpr("status == ''")!
|
||||
expect(evaluateExpr(ast, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('converts non-string values to string', () => {
|
||||
const ast = parseExpr("count == '5'")!
|
||||
expect(evaluateExpr(ast, { count: 5 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateVisibleWhen', () => {
|
||||
it('returns true for undefined expression', () => {
|
||||
expect(evaluateVisibleWhen(undefined, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for empty string expression', () => {
|
||||
expect(evaluateVisibleWhen('', {})).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates complex expression', () => {
|
||||
expect(
|
||||
evaluateVisibleWhen("type == 'doctor' AND status == 'active'", {
|
||||
type: 'doctor',
|
||||
status: 'active',
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
evaluateVisibleWhen("type == 'doctor' AND status == 'active'", {
|
||||
type: 'doctor',
|
||||
status: 'inactive',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
150
apps/web/src/utils/exprEvaluator.ts
Normal file
150
apps/web/src/utils/exprEvaluator.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* visible_when 表达式解析与求值
|
||||
*
|
||||
* 支持语法:
|
||||
* field == 'value' 等值判断
|
||||
* field != 'value' 不等判断
|
||||
* expr1 AND expr2 逻辑与
|
||||
* expr1 OR expr2 逻辑或
|
||||
* NOT expr 逻辑非
|
||||
* (expr) 括号分组
|
||||
*/
|
||||
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'neq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (input[i] === ' ') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '(' || input[i] === ')') {
|
||||
tokens.push(input[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === "'") {
|
||||
let j = i + 1;
|
||||
while (j < input.length && input[j] !== "'") j++;
|
||||
tokens.push(input.substring(i, j + 1));
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '=' && input[i + 1] === '=') {
|
||||
tokens.push('==');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '!' && input[i + 1] === '=') {
|
||||
tokens.push('!=');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '&' && input[i + 1] === '&') {
|
||||
tokens.push('&&');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '|' && input[i + 1] === '|') {
|
||||
tokens.push('||');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (
|
||||
j < input.length &&
|
||||
!' ()\''.includes(input[j]) &&
|
||||
!(input[j] === '=' && input[j + 1] === '=') &&
|
||||
!(input[j] === '!' && input[j + 1] === '=')
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
tokens.push(input.substring(i, j));
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseAtom(tokens: string[]): ExprNode | null {
|
||||
const token = tokens.shift();
|
||||
if (!token) return null;
|
||||
if (token === '(') {
|
||||
const expr = parseOr(tokens);
|
||||
if (tokens[0] === ')') tokens.shift();
|
||||
return expr;
|
||||
}
|
||||
if (token === 'NOT') {
|
||||
const operand = parseAtom(tokens);
|
||||
return { type: 'not', operand: operand || undefined };
|
||||
}
|
||||
const field = token;
|
||||
const op = tokens.shift();
|
||||
if (op !== '==' && op !== '!=') return null;
|
||||
const rawValue = tokens.shift() || '';
|
||||
const value = rawValue.replace(/^'(.*)'$/, '$1');
|
||||
return { type: op === '!=' ? 'neq' : 'eq', field, value };
|
||||
}
|
||||
|
||||
function parseAnd(tokens: string[]): ExprNode | null {
|
||||
let left = parseAtom(tokens);
|
||||
while (tokens[0] === 'AND' || tokens[0] === '&&') {
|
||||
tokens.shift();
|
||||
const right = parseAtom(tokens);
|
||||
if (left && right) {
|
||||
left = { type: 'and', left, right };
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseOr(tokens: string[]): ExprNode | null {
|
||||
let left = parseAnd(tokens);
|
||||
while (tokens[0] === 'OR' || tokens[0] === '||') {
|
||||
tokens.shift();
|
||||
const right = parseAnd(tokens);
|
||||
if (left && right) {
|
||||
left = { type: 'or', left, right };
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function parseExpr(input: string): ExprNode | null {
|
||||
const tokens = tokenize(input);
|
||||
return parseOr(tokens);
|
||||
}
|
||||
|
||||
export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean {
|
||||
switch (node.type) {
|
||||
case 'eq':
|
||||
return String(values[node.field!] ?? '') === node.value;
|
||||
case 'neq':
|
||||
return String(values[node.field!] ?? '') !== node.value;
|
||||
case 'and':
|
||||
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
|
||||
case 'or':
|
||||
return evaluateExpr(node.left!, values) || evaluateExpr(node.right!, values);
|
||||
case 'not':
|
||||
return !evaluateExpr(node.operand!, values);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateVisibleWhen(
|
||||
expr: string | undefined,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!expr) return true;
|
||||
const ast = parseExpr(expr);
|
||||
return ast ? evaluateExpr(ast, values) : true;
|
||||
}
|
||||
16
apps/web/src/utils/format.ts
Normal file
16
apps/web/src/utils/format.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { dayjs } from './dayjs';
|
||||
|
||||
export const formatDate = (v: string | null | undefined): string =>
|
||||
v ? dayjs(v).format('YYYY-MM-DD') : '--';
|
||||
|
||||
export const formatDateTime = (v: string | null | undefined): string =>
|
||||
v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '--';
|
||||
|
||||
export const formatRelative = (v: string | null | undefined): string =>
|
||||
v ? dayjs(v).fromNow() : '--';
|
||||
|
||||
export const calcAge = (birthDate: string | null | undefined): string => {
|
||||
if (!birthDate) return '--';
|
||||
const age = dayjs().diff(dayjs(birthDate), 'year');
|
||||
return age >= 0 ? `${age}岁` : '--';
|
||||
};
|
||||
107
apps/web/src/utils/iconRegistry.tsx
Normal file
107
apps/web/src/utils/iconRegistry.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 图标注册表 — 菜单图标名称 → React 组件的单一真相源
|
||||
*
|
||||
* 后端 menus.icon 字段存储图标名称字符串(如 "HomeOutlined"),
|
||||
* 前端通过此注册表将其转换为对应的 Ant Design 图标组件。
|
||||
*
|
||||
* 新增后端 seed 图标时必须同步在此添加映射,否则侧边栏回退为 AppstoreOutlined。
|
||||
*/
|
||||
|
||||
import {
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
SettingOutlined,
|
||||
PartitionOutlined,
|
||||
MessageOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
TableOutlined,
|
||||
TagsOutlined,
|
||||
HeartOutlined,
|
||||
CalendarOutlined,
|
||||
PhoneOutlined,
|
||||
CommentOutlined,
|
||||
MedicineBoxOutlined,
|
||||
TrophyOutlined,
|
||||
ShopOutlined,
|
||||
FileTextOutlined,
|
||||
DashboardOutlined,
|
||||
RobotOutlined,
|
||||
HistoryOutlined,
|
||||
BarChartOutlined,
|
||||
AlertOutlined,
|
||||
BellOutlined,
|
||||
ControlOutlined,
|
||||
InboxOutlined,
|
||||
ApiOutlined,
|
||||
ReadOutlined,
|
||||
ExperimentOutlined,
|
||||
// 以下为后端 seed 使用但原 iconMap 缺失的图标
|
||||
AuditOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileSearchOutlined,
|
||||
FormOutlined,
|
||||
MonitorOutlined,
|
||||
PictureOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SolutionOutlined,
|
||||
SwapOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export const iconRegistry: Record<string, ReactNode> = {
|
||||
// 基础模块
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
SafetyOutlined: <SafetyOutlined />,
|
||||
ApartmentOutlined: <ApartmentOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
PartitionOutlined: <PartitionOutlined />,
|
||||
MessageOutlined: <MessageOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
TableOutlined: <TableOutlined />,
|
||||
TagsOutlined: <TagsOutlined />,
|
||||
SearchOutlined: <AppstoreOutlined />, // 搜索无专属侧边栏图标
|
||||
|
||||
// 健康模块
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CalendarOutlined: <CalendarOutlined />,
|
||||
PhoneOutlined: <PhoneOutlined />,
|
||||
CommentOutlined: <CommentOutlined />,
|
||||
MedicineBoxOutlined: <MedicineBoxOutlined />,
|
||||
TrophyOutlined: <TrophyOutlined />,
|
||||
ShopOutlined: <ShopOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
BarChartOutlined: <BarChartOutlined />,
|
||||
AlertOutlined: <AlertOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
ControlOutlined: <ControlOutlined />,
|
||||
InboxOutlined: <InboxOutlined />,
|
||||
ApiOutlined: <ApiOutlined />,
|
||||
ReadOutlined: <ReadOutlined />,
|
||||
ExperimentOutlined: <ExperimentOutlined />,
|
||||
|
||||
// 健康模块(补充原缺失)
|
||||
AuditOutlined: <AuditOutlined />,
|
||||
ClockCircleOutlined: <ClockCircleOutlined />,
|
||||
FileSearchOutlined: <FileSearchOutlined />,
|
||||
FormOutlined: <FormOutlined />,
|
||||
MonitorOutlined: <MonitorOutlined />,
|
||||
PictureOutlined: <PictureOutlined />,
|
||||
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
|
||||
SolutionOutlined: <SolutionOutlined />,
|
||||
SwapOutlined: <SwapOutlined />,
|
||||
WifiOutlined: <WifiOutlined />,
|
||||
};
|
||||
|
||||
export function getIcon(name?: string): ReactNode {
|
||||
if (!name) return <AppstoreOutlined />;
|
||||
return iconRegistry[name] || <AppstoreOutlined />;
|
||||
}
|
||||
14
apps/web/src/utils/media.ts
Normal file
14
apps/web/src/utils/media.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 将后端返回的 storage_path / thumbnail_path 转换为可访问的前端 URL。
|
||||
*
|
||||
* 后端存储路径格式: "./uploads/{tenant_id}/{filename}" 或 "/uploads/..."
|
||||
* 前端统一使用相对路径 "/uploads/...",由 Vite(dev)或 nginx(prod)代理到后端。
|
||||
*
|
||||
* 如需认证,自动附加 ?token= 参数。
|
||||
*/
|
||||
export function resolveMediaUrl(rawPath: string | null | undefined): string {
|
||||
if (!rawPath) return '';
|
||||
const base = rawPath.replace(/^\.\//, '/');
|
||||
const token = localStorage.getItem('access_token');
|
||||
return token ? `${base}?token=${token}` : base;
|
||||
}
|
||||
Reference in New Issue
Block a user