feat: 添加管理端前端 (HMS 基座 React 管理面板)
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

- 从 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:
iven
2026-06-02 10:03:13 +08:00
parent 181bfb1f3e
commit 8111471e93
341 changed files with 72102 additions and 1059 deletions

View 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;

View 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)
})
})

View 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;
}

View 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}` : '--';
};

View 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 />;
}

View File

@@ -0,0 +1,14 @@
/**
* 将后端返回的 storage_path / thumbnail_path 转换为可访问的前端 URL。
*
* 后端存储路径格式: "./uploads/{tenant_id}/{filename}" 或 "/uploads/..."
* 前端统一使用相对路径 "/uploads/...",由 Vitedev或 nginxprod代理到后端。
*
* 如需认证,自动附加 ?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;
}