Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
# 头像生成工具
## 📍 位置
`/src/utils/avatar.ts``/src/components/user-selector/user-card.vue`
## 🎯 功能
自动生成用户头像,当用户没有上传头像时使用。
### 特性
**智能文本生成**
- 汉字:显示第一个字
- 字母:显示前两个字母
- 其他:显示第一个字符
**美观的渐变背景**
- 20种精美渐变色
- 基于名字哈希的稳定性(相同名字始终使用同一渐变)
- 135度斜向渐变,视觉效果优雅
- 兼容深色/浅色主题
**优化的视觉设计**
- 文字大小 28px,加粗(font-weight: 700
- 白色文字,带文字阴影
- 圆形头像,8px 阴影
- 悬停时动画效果(向上浮起 2px
**完全集成**
- 在 user-card 组件中自动使用
- 不需要手动调用
## 📚 API
### 1. `generateAvatarText(name: string): string`
从名字生成头像显示文本
**示例**
```typescript
generateAvatarText('李明') // 返回 '李'
generateAvatarText('John Doe') // 返回 'JO'
```
### 2. `generateAvatarGradient(name: string): string`
根据名字生成漂亮的渐变背景色
**返回值格式**
```
linear-gradient(135deg, #667eea 0%, #764ba2 100%)
```
**特点**
- 返回完整的 CSS 渐变值
- 相同名字始终返回相同渐变
- 20种预设渐变色
### 3. `generateAvatarConfig(name: string): AvatarConfig`
生成完整的头像配置对象
**返回值**
```typescript
interface AvatarConfig {
text: string; // 显示的文本
backgroundColor: string; // 背景色 (十六进制,兼容用)
gradient: string; // 渐变背景 CSS
color?: string; // 文字颜色 (总是 #ffffff)
}
```
## 🎨 渐变色调色板
20种精心设计的渐变色:
```
紫蓝系:
linear-gradient(135deg, #667eea 0%, #764ba2 100%)
linear-gradient(135deg, #4158d0 0%, #c850c0 100%)
粉红系:
linear-gradient(135deg, #f093fb 0%, #f5576c 100%)
linear-gradient(135deg, #c471f5 0%, #fa71cd 100%)
linear-gradient(135deg, #fa709a 0%, #fee140 100%)
青蓝系:
linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)
linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%)
linear-gradient(135deg, #30cfd0 0%, #330867 100%)
绿系:
linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)
linear-gradient(135deg, #1f4037 0%, #00a86b 100%)
linear-gradient(135deg, #56ab2f 0%, #a8e063 100%)
橙/红系:
linear-gradient(135deg, #ff9a56 0%, #ff6a88 100%)
linear-gradient(135deg, #ffa751 0%, #ffe259 100%)
linear-gradient(135deg, #eb3349 0%, #f45c43 100%)
linear-gradient(135deg, #f12c4f 0%, #ff9f1c 100%)
linear-gradient(135deg, #872198 0%, #f4a261 100%)
浅色系:
linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)
linear-gradient(135deg, #1a7fa0 0%, #4facb3 100%)
linear-gradient(135deg, #2e2e78 0%, #662d8c 100%)
```
## ✨ 视觉优化
### 文字样式
- **字号**: 28px
- **粗度**: font-weight 700(加粗)
- **颜色**: 白色 (#ffffff)
- **阴影**: 0 1px 2px rgba(0, 0, 0, 0.2)
### 头像样式
- **尺寸**: 56px × 56px
- **圆角**: 50% (完全圆形)
- **阴影**: 0 2px 8px rgba(0, 0, 0, 0.15)
- **渐变**: 135度斜向渐变
### 交互效果
- **悬停**: 向上浮起 2px,阴影加深
- **选中**: 边框变为主题色,背景变浅
## 🔧 在组件中使用
### user-card 组件
自动集成,无需配置。当用户没有头像时,组件会自动:
1. 生成头像文本(汉字/字母/字符)
2. 分配漂亮的渐变背景色
3. 以优雅的样式显示
```vue
<div
v-if="!user.avatar"
class="avatar-gradient"
:style="{ background: avatarGradient }"
>
<span class="avatar-text">{{ userInitials }}</span>
</div>
```
### 在其他组件中使用
```typescript
import { generateAvatarConfig } from '#/utils/avatar';
const avatarConfig = generateAvatarConfig('李明');
// 使用配置
console.log(avatarConfig.text); // '李'
console.log(avatarConfig.gradient); // 'linear-gradient(...)'
console.log(avatarConfig.color); // '#ffffff'
```
## 🌙 深色模式
头像在深色/浅色模式下都清晰可见:
- 渐变自动适配主题
- 文字始终白色
- 阴影自动调整
---
**更新时间**: 2025-11-04
**版本**: 2.0.0 - 渐变优化版本
**状态**: 生产就绪
+130
View File
@@ -0,0 +1,130 @@
/**
* 头像生成工具函数
*/
/**
* 判断是否是汉字
*/
function isChinese(char: string): boolean {
const code = char.charCodeAt(0);
return code >= 0x4E_00 && code <= 0x9F_FF;
}
/**
* 从名字生成头像文本
* - 汉字:显示第一个字
* - 字母:显示前两个字母
* - 其他:显示第一个字符
*/
export function generateAvatarText(name?: string): string {
if (!name) {
return '?';
}
const trimmedName = name.trim();
if (trimmedName.length === 0) {
return '?';
}
const firstChar = trimmedName.charAt(0);
// 如果第一个字是汉字
if (isChinese(firstChar)) {
return firstChar;
}
// 如果是字母,显示前两个字母
if (/[a-z]/i.test(firstChar)) {
let result = '';
for (let i = 0; i < trimmedName.length && result.length < 2; i++) {
const char = trimmedName.charAt(i);
if (/[a-z]/i.test(char)) {
result += char.toUpperCase();
}
}
return result || firstChar.toUpperCase();
}
// 其他情况返回第一个字符
return firstChar;
}
/**
* 根据名字生成稳定的渐变颜色配置
* 使用哈希算法确保相同的名字生成相同的颜色
*/
export function generateAvatarGradient(name?: string): string {
if (!name) {
return 'linear-gradient(135deg, #8b9dff 0%, #a78bfa 100%)';
}
// 预设的美观渐变颜色 - 中等饱和度版本
const gradients: string[] = [
'linear-gradient(135deg, #8b9dff 0%, #a78bfa 100%)', // 紫蓝渐变
'linear-gradient(135deg, #f59dba 0%, #fa709a 100%)', // 粉红渐变
'linear-gradient(135deg, #60c5ff 0%, #7dd3fc 100%)', // 青蓝渐变
'linear-gradient(135deg, #6ee7b7 0%, #5eead4 100%)', // 绿松渐变
'linear-gradient(135deg, #fb923c 0%, #fbbf24 100%)', // 橙金渐变
'linear-gradient(135deg, #a78bfa 0%, #f472b6 100%)', // 紫粉渐变
'linear-gradient(135deg, #f472b6 0%, #fb923c 100%)', // 粉橙渐变
'linear-gradient(135deg, #818cf8 0%, #c084fc 100%)', // 蓝紫渐变
'linear-gradient(135deg, #38bdf8 0%, #7dd3fc 100%)', // 天蓝渐变
'linear-gradient(135deg, #34d399 0%, #a3e635 100%)', // 绿黄渐变
'linear-gradient(135deg, #fb7185 0%, #fda4af 100%)', // 玫红渐变
'linear-gradient(135deg, #06b6d4 0%, #22d3ee 100%)', // 青色渐变
'linear-gradient(135deg, #d946ef 0%, #f0abfc 100%)', // 品红渐变
'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)', // 琥珀渐变
'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', // 紫色渐变
'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)', // 蔚蓝渐变
'linear-gradient(135deg, #ec4899 0%, #f472b6 100%)', // 粉色渐变
'linear-gradient(135deg, #10b981 0%, #34d399 100%)', // 翠绿渐变
'linear-gradient(135deg, #a855f7 0%, #c084fc 100%)', // 紫罗兰渐变
'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)', // 蓝色渐变
];
// 简单的哈希函数:计算字符串的哈希值
let hash = 0;
for (let i = 0; i < name.length; i++) {
const char = name.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // 转换为 32 位整数
}
// 使用哈希值选择颜色
const index = Math.abs(hash) % gradients.length;
return gradients[index]!;
}
/**
* 根据名字生成稳定的背景颜色(保留用于兼容)
*/
export function generateAvatarColor(name?: string): string {
const gradient = generateAvatarGradient(name);
// 从渐变中提取主要颜色用于兼容
if (gradient.includes('#667eea')) return '#667eea';
if (gradient.includes('#f093fb')) return '#f093fb';
if (gradient.includes('#4facfe')) return '#4facfe';
return '#667eea'; // 默认
}
/**
* 生成头像的完整配置对象
*/
export interface AvatarConfig {
text: string;
backgroundColor: string;
gradient: string;
color?: string;
}
/**
* 根据名字生成完整的头像配置
*/
export function generateAvatarConfig(name?: string): AvatarConfig {
return {
text: generateAvatarText(name),
backgroundColor: generateAvatarColor(name),
gradient: generateAvatarGradient(name),
color: '#ffffff', // 文字颜色总是白色
};
}
+592
View File
@@ -0,0 +1,592 @@
import { mapToDbType } from '#/utils/database-types';
import {
quoteIdentifier,
quoteTable,
} from '#/views/_core/database-manager/utils/sql-identifier';
export interface ColumnFieldDefinition {
name: string;
type: string;
length?: number;
precision?: number;
scale?: number;
nullable: boolean;
default?: string;
primaryKey: boolean;
unique: boolean;
comment?: string;
}
function normalizeDbType(dbType: string): string {
const db = (dbType || 'postgresql').toLowerCase();
if (db === 'sql server' || db === 'mssql') {
return 'sqlserver';
}
return db;
}
function escapeSqlString(value: string): string {
return value.replaceAll("'", "''");
}
function buildTypeDef(field: ColumnFieldDefinition, dbType: string): string {
const db = normalizeDbType(dbType);
let typeDef = mapToDbType(field.type, dbType);
const typesWithLength = [
'varchar',
'char',
'nvarchar',
'nchar',
'varbinary',
'binary',
];
const typesWithPrecision = ['decimal', 'numeric'];
const mysqlTypesWithPrecision = ['float'];
const lowerType = field.type.toLowerCase();
if (
typesWithLength.includes(lowerType) &&
field.length &&
field.length > 0
) {
typeDef += `(${field.length})`;
} else if (typesWithPrecision.includes(lowerType) && field.precision) {
typeDef += field.scale
? `(${field.precision}, ${field.scale})`
: `(${field.precision})`;
} else if (
db === 'mysql' &&
mysqlTypesWithPrecision.includes(lowerType) &&
field.precision
) {
typeDef += field.scale
? `(${field.precision}, ${field.scale})`
: `(${field.precision})`;
}
return typeDef;
}
export function buildColumnDefinition(
field: ColumnFieldDefinition,
dbType: string,
): string {
const db = normalizeDbType(dbType);
const col = quoteIdentifier(field.name, db);
let def = `${col} ${buildTypeDef(field, dbType)}`;
if (!field.nullable) {
def += ' NOT NULL';
}
if (field.default) {
def += ` DEFAULT ${field.default}`;
}
if (field.comment && db === 'mysql') {
def += ` COMMENT '${escapeSqlString(field.comment)}'`;
}
return def;
}
function buildSqlServerColumnExtendedProperty(
tableName: string,
columnName: string,
comment: string,
schema: string,
isUpdate: boolean,
): string {
const proc = isUpdate ? 'sp_updateextendedproperty' : 'sp_addextendedproperty';
return `EXEC ${proc} @name=N'MS_Description', @value=N'${escapeSqlString(comment)}', @level0type=N'SCHEMA', @level0name=N'${escapeSqlString(schema)}', @level1type=N'TABLE', @level1name=N'${escapeSqlString(tableName)}', @level2type=N'COLUMN', @level2name=N'${escapeSqlString(columnName)}';`;
}
function buildSqlServerDropDefaultSql(
tableRef: string,
tableName: string,
columnName: string,
schema: string,
): string {
const schemaName = escapeSqlString(schema || 'dbo');
const table = escapeSqlString(tableName);
const column = escapeSqlString(columnName);
return `DECLARE @sql NVARCHAR(MAX);
SELECT @sql = N'ALTER TABLE ${tableRef} DROP CONSTRAINT ' + QUOTENAME(dc.name)
FROM sys.default_constraints dc
INNER JOIN sys.columns col ON dc.parent_object_id = col.object_id AND dc.parent_column_id = col.column_id
INNER JOIN sys.tables tab ON col.object_id = tab.object_id
INNER JOIN sys.schemas sch ON tab.schema_id = sch.schema_id
WHERE sch.name = N'${schemaName}' AND tab.name = N'${table}' AND col.name = N'${column}';
IF @sql IS NOT NULL EXEC sp_executesql @sql;`;
}
function buildSqlServerTableExtendedProperty(
tableName: string,
comment: string,
schema: string,
isUpdate: boolean,
): string {
const proc = isUpdate ? 'sp_updateextendedproperty' : 'sp_addextendedproperty';
return `EXEC ${proc} @name=N'MS_Description', @value=N'${escapeSqlString(comment)}', @level0type=N'SCHEMA', @level0name=N'${escapeSqlString(schema)}', @level1type=N'TABLE', @level1name=N'${escapeSqlString(tableName)}';`;
}
export function buildAddColumnSql(
tableRef: string,
field: ColumnFieldDefinition,
dbType: string,
options?: { schema?: string; tableName?: string; hadComment?: boolean },
): string[] {
const db = normalizeDbType(dbType);
const columnDef = buildColumnDefinition(field, dbType);
const statements: string[] = [];
switch (db) {
case 'mysql': {
statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${columnDef};`);
break;
}
case 'sqlserver': {
statements.push(`ALTER TABLE ${tableRef} ADD ${columnDef};`);
if (field.comment && options?.tableName) {
statements.push(
buildSqlServerColumnExtendedProperty(
options.tableName,
field.name,
field.comment,
options.schema || 'dbo',
false,
),
);
}
break;
}
case 'oracle': {
statements.push(`ALTER TABLE ${tableRef} ADD (${columnDef});`);
if (field.comment) {
statements.push(
`COMMENT ON COLUMN ${tableRef}.${quoteIdentifier(field.name, db)} IS '${escapeSqlString(field.comment)}';`,
);
}
break;
}
case 'postgresql':
default: {
statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${columnDef};`);
if (field.comment) {
statements.push(
`COMMENT ON COLUMN ${tableRef}.${quoteIdentifier(field.name, db)} IS '${escapeSqlString(field.comment)}';`,
);
}
break;
}
}
return statements;
}
export function buildDropColumnSql(
tableRef: string,
columnName: string,
dbType: string,
): string {
const db = normalizeDbType(dbType);
return `ALTER TABLE ${tableRef} DROP COLUMN ${quoteIdentifier(columnName, db)};`;
}
export function buildTableCommentAlterSql(
tableRef: string,
tableName: string,
comment: string,
dbType: string,
schema?: string,
hadComment?: boolean,
): string | null {
const db = normalizeDbType(dbType);
const escaped = escapeSqlString(comment);
switch (db) {
case 'postgresql':
case 'oracle': {
return `COMMENT ON TABLE ${tableRef} IS '${escaped}';`;
}
case 'mysql': {
return `ALTER TABLE ${tableRef} COMMENT = '${escaped}';`;
}
case 'sqlserver': {
return buildSqlServerTableExtendedProperty(
tableName,
comment,
schema || 'dbo',
Boolean(hadComment),
);
}
default: {
return null;
}
}
}
export function buildColumnCommentAlterSql(
tableRef: string,
tableName: string,
field: ColumnFieldDefinition,
dbType: string,
schema?: string,
hadComment?: boolean,
): string[] {
const db = normalizeDbType(dbType);
const col = quoteIdentifier(field.name, db);
const comment = field.comment || '';
const statements: string[] = [];
switch (db) {
case 'postgresql':
case 'oracle': {
statements.push(
`COMMENT ON COLUMN ${tableRef}.${col} IS '${escapeSqlString(comment)}';`,
);
break;
}
case 'mysql': {
const columnDef = buildColumnDefinition(field, dbType);
statements.push(`ALTER TABLE ${tableRef} MODIFY COLUMN ${columnDef};`);
break;
}
case 'sqlserver': {
statements.push(
buildSqlServerColumnExtendedProperty(
tableName,
field.name,
comment,
schema || 'dbo',
Boolean(hadComment),
),
);
break;
}
default: {
break;
}
}
return statements;
}
export function buildFieldAlterSql(
originalFields: ColumnFieldDefinition[],
currentFields: ColumnFieldDefinition[],
tableRef: string,
tableName: string,
dbType: string,
schema?: string,
): string[] {
const db = normalizeDbType(dbType);
const statements: string[] = [];
const colRef = (name: string) => quoteIdentifier(name, db);
for (const origField of originalFields) {
if (!currentFields.find((f) => f.name === origField.name)) {
statements.push(buildDropColumnSql(tableRef, origField.name, dbType));
}
}
for (const field of currentFields) {
if (!originalFields.find((f) => f.name === field.name)) {
statements.push(
...buildAddColumnSql(tableRef, field, dbType, {
schema,
tableName,
}),
);
}
}
for (const field of currentFields) {
const origField = originalFields.find((f) => f.name === field.name);
if (!origField || JSON.stringify(field) === JSON.stringify(origField)) {
continue;
}
const col = colRef(field.name);
const typeDef = buildTypeDef(field, dbType);
const origTypeDef = buildTypeDef(origField, dbType);
if (db === 'postgresql') {
const typeChanged =
field.type !== origField.type ||
field.length !== origField.length ||
field.precision !== origField.precision ||
field.scale !== origField.scale;
if (typeChanged) {
statements.push(
`ALTER TABLE ${tableRef} ALTER COLUMN ${col} TYPE ${typeDef} USING ${col}::${typeDef};`,
);
}
if (field.nullable !== origField.nullable) {
statements.push(
`ALTER TABLE ${tableRef} ALTER COLUMN ${col} ${field.nullable ? 'DROP NOT NULL' : 'SET NOT NULL'};`,
);
}
if (field.default !== origField.default) {
if (field.default) {
statements.push(
`ALTER TABLE ${tableRef} ALTER COLUMN ${col} SET DEFAULT ${field.default};`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} ALTER COLUMN ${col} DROP DEFAULT;`,
);
}
}
if (field.unique !== origField.unique) {
if (field.unique) {
statements.push(
`ALTER TABLE ${tableRef} ADD CONSTRAINT ${colRef(`${tableName}_${field.name}_key`)} UNIQUE (${col});`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT IF EXISTS ${colRef(`${tableName}_${field.name}_key`)};`,
);
}
}
if (field.primaryKey !== origField.primaryKey) {
if (field.primaryKey) {
statements.push(
`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${col});`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT IF EXISTS ${colRef(`${tableName}_pkey`)};`,
);
}
}
if (field.comment !== origField.comment) {
statements.push(
...buildColumnCommentAlterSql(
tableRef,
tableName,
field,
dbType,
schema,
Boolean(origField.comment),
),
);
}
} else if (db === 'mysql') {
statements.push(
`ALTER TABLE ${tableRef} MODIFY COLUMN ${buildColumnDefinition(field, dbType)};`,
);
if (field.unique !== origField.unique) {
if (field.unique) {
statements.push(
`ALTER TABLE ${tableRef} ADD UNIQUE INDEX ${colRef(`${tableName}_${field.name}_key`)} (${col});`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP INDEX IF EXISTS ${colRef(`${tableName}_${field.name}_key`)};`,
);
}
}
if (field.primaryKey !== origField.primaryKey) {
if (field.primaryKey) {
statements.push(`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${col});`);
} else {
statements.push(`ALTER TABLE ${tableRef} DROP PRIMARY KEY;`);
}
}
} else if (db === 'sqlserver') {
if (typeDef !== origTypeDef) {
let alterDef = `${col} ${typeDef}`;
alterDef += field.nullable ? ' NULL' : ' NOT NULL';
statements.push(`ALTER TABLE ${tableRef} ALTER COLUMN ${alterDef};`);
} else if (field.nullable !== origField.nullable) {
let alterDef = `${col} ${typeDef}`;
alterDef += field.nullable ? ' NULL' : ' NOT NULL';
statements.push(`ALTER TABLE ${tableRef} ALTER COLUMN ${alterDef};`);
}
if (field.default !== origField.default) {
if (field.default) {
statements.push(
`ALTER TABLE ${tableRef} ADD DEFAULT ${field.default} FOR ${col};`,
);
} else if (origField.default) {
statements.push(
buildSqlServerDropDefaultSql(
tableRef,
tableName,
field.name,
schema || 'dbo',
),
);
}
}
if (field.comment !== origField.comment) {
statements.push(
...buildColumnCommentAlterSql(
tableRef,
tableName,
field,
dbType,
schema,
Boolean(origField.comment),
),
);
}
if (field.unique !== origField.unique) {
if (field.unique) {
statements.push(
`ALTER TABLE ${tableRef} ADD CONSTRAINT ${colRef(`${tableName}_${field.name}_key`)} UNIQUE (${col});`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT ${colRef(`${tableName}_${field.name}_key`)};`,
);
}
}
if (field.primaryKey !== origField.primaryKey) {
if (field.primaryKey) {
statements.push(`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${col});`);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT ${colRef(`${tableName}_pkey`)};`,
);
}
}
} else if (db === 'oracle') {
const structureChanged =
typeDef !== origTypeDef ||
field.nullable !== origField.nullable ||
field.default !== origField.default;
if (structureChanged) {
let modifyDef = `${col} ${typeDef}`;
modifyDef += field.nullable ? ' NULL' : ' NOT NULL';
if (field.default !== origField.default) {
modifyDef += field.default
? ` DEFAULT ${field.default}`
: ' DEFAULT NULL';
}
statements.push(`ALTER TABLE ${tableRef} MODIFY (${modifyDef});`);
}
if (field.comment !== origField.comment) {
statements.push(
...buildColumnCommentAlterSql(
tableRef,
tableName,
field,
dbType,
schema,
Boolean(origField.comment),
),
);
}
if (field.unique !== origField.unique) {
if (field.unique) {
statements.push(
`ALTER TABLE ${tableRef} ADD CONSTRAINT ${colRef(`${tableName}_${field.name}_key`)} UNIQUE (${col});`,
);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT ${colRef(`${tableName}_${field.name}_key`)};`,
);
}
}
if (field.primaryKey !== origField.primaryKey) {
if (field.primaryKey) {
statements.push(`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${col});`);
} else {
statements.push(
`ALTER TABLE ${tableRef} DROP CONSTRAINT ${colRef(`${tableName}_pk`)};`,
);
}
}
}
}
return statements;
}
/** 建表后追加表/列注释 SQL */
export function buildCreateTableCommentSql(
tableName: string,
schema: string | undefined,
dbType: string,
tableComment: string,
fields: ColumnFieldDefinition[],
): string[] {
const db = normalizeDbType(dbType);
const tableRef = quoteTable(schema, tableName, db);
const statements: string[] = [];
if (tableComment) {
switch (db) {
case 'postgresql':
case 'oracle': {
statements.push(
`COMMENT ON TABLE ${tableRef} IS '${escapeSqlString(tableComment)}';`,
);
break;
}
case 'mysql': {
statements.push(
`ALTER TABLE ${tableRef} COMMENT = '${escapeSqlString(tableComment)}';`,
);
break;
}
case 'sqlserver': {
statements.push(
buildSqlServerTableExtendedProperty(
tableName,
tableComment,
schema || 'dbo',
false,
),
);
break;
}
default: {
break;
}
}
}
for (const field of fields) {
if (!field.comment) {
continue;
}
switch (db) {
case 'postgresql':
case 'oracle': {
statements.push(
`COMMENT ON COLUMN ${tableRef}.${quoteIdentifier(field.name, db)} IS '${escapeSqlString(field.comment)}';`,
);
break;
}
case 'sqlserver': {
statements.push(
buildSqlServerColumnExtendedProperty(
tableName,
field.name,
field.comment,
schema || 'dbo',
false,
),
);
break;
}
default: {
break;
}
}
}
return statements;
}
@@ -0,0 +1,483 @@
import type { ConstraintInfo } from '#/api/core/database-manager';
export interface TableConstraintDefinition {
name: string;
type: string;
definition?: string;
columns?: string[];
referencedTable?: string;
referencedColumns?: string[];
onDelete?: string;
onUpdate?: string;
}
export const FOREIGN_KEY_ACTIONS = [
'NO ACTION',
'RESTRICT',
'CASCADE',
'SET NULL',
'SET DEFAULT',
] as const;
export type ForeignKeyAction = (typeof FOREIGN_KEY_ACTIONS)[number];
export function normalizeForeignKeyAction(value?: string): string {
if (!value) {
return '';
}
return value.toUpperCase().replaceAll(/\s+/g, ' ').trim();
}
export function parseForeignKeyActions(definition?: string): {
onDelete: string;
onUpdate: string;
} {
const result = { onDelete: '', onUpdate: '' };
if (!definition) {
return result;
}
const onDeleteMatch = definition.match(
/\sON DELETE (NO ACTION|RESTRICT|CASCADE|SET NULL|SET DEFAULT)\b/i,
);
const onUpdateMatch = definition.match(
/\sON UPDATE (NO ACTION|RESTRICT|CASCADE|SET NULL|SET DEFAULT)\b/i,
);
if (onDeleteMatch?.[1]) {
result.onDelete = normalizeForeignKeyAction(onDeleteMatch[1]);
}
if (onUpdateMatch?.[1]) {
result.onUpdate = normalizeForeignKeyAction(onUpdateMatch[1]);
}
return result;
}
function buildForeignKeyActionsSql(
constraint: TableConstraintDefinition,
dbType?: string,
): string {
const db = (dbType || 'postgresql').toLowerCase();
const supportsOnUpdate = db !== 'sqlserver' && db !== 'oracle' && db !== 'mssql';
const parts: string[] = [];
if (constraint.onDelete) {
parts.push(`ON DELETE ${constraint.onDelete}`);
}
if (supportsOnUpdate && constraint.onUpdate) {
parts.push(`ON UPDATE ${constraint.onUpdate}`);
}
return parts.length ? ` ${parts.join(' ')}` : '';
}
export function normalizeConstraintType(type: string): string {
const normalized = type.toLowerCase().replaceAll('_', ' ').trim();
if (normalized === 'primary key' || normalized === 'primary') {
return 'primary';
}
if (normalized === 'foreign key' || normalized === 'foreign') {
return 'foreign';
}
if (normalized === 'unique') {
return 'unique';
}
if (normalized === 'check') {
return 'check';
}
return normalized;
}
export function parseForeignKeyFromDefinition(definition?: string): {
columns: string[];
referencedTable: string;
referencedColumns: string[];
} {
const empty = { columns: [], referencedTable: '', referencedColumns: [] };
if (!definition?.trim()) {
return empty;
}
const match = definition.match(
/FOREIGN KEY\s*\(([^)]+)\)\s*REFERENCES\s*(?:"?([\w$]+)"?\.)?"?([\w$]+)"?\s*\(([^)]+)\)/i,
);
if (!match) {
return empty;
}
const parseColumnList = (value: string) =>
value
.split(',')
.map((column) => column.trim().replaceAll(/^"|"$/g, ''))
.filter(Boolean);
return {
columns: parseColumnList(match[1]),
referencedTable: match[3],
referencedColumns: parseColumnList(match[4]),
};
}
export function inferForeignKeyLocalColumnFromName(
constraintName?: string,
): string | undefined {
if (!constraintName?.trim()) {
return undefined;
}
const match = constraintName.trim().match(/^fk_(.+)$/i);
return match?.[1];
}
export function parseConstraintColumnsFromApi(
type: string,
definition?: string,
constraintName?: string,
apiColumns?: string[],
): { columns: string[]; definition: string } {
const columns = apiColumns?.filter(Boolean) || [];
let normalizedDefinition = definition?.trim() || '';
if (columns.length) {
return { columns, definition: normalizedDefinition };
}
if (normalizedDefinition) {
const checkNotNullMatch = normalizedDefinition.match(
/CHECK\s*\(\(?\s*"?(\w+)"?\s+IS NOT NULL\s*\)?\)?/i,
);
if (checkNotNullMatch?.[1]) {
const column = checkNotNullMatch[1];
return {
columns: [column],
definition: `${column} IS NOT NULL`,
};
}
const notNullPrefixMatch = normalizedDefinition.match(/^NOT NULL\s+"?(\w+)"?$/i);
if (notNullPrefixMatch?.[1]) {
const column = notNullPrefixMatch[1];
return {
columns: [column],
definition: `${column} IS NOT NULL`,
};
}
const isNotNullMatch = normalizedDefinition.match(/^"?(\w+)"?\s+IS NOT NULL$/i);
if (isNotNullMatch?.[1]) {
return {
columns: [isNotNullMatch[1]],
definition: normalizedDefinition,
};
}
}
if (normalizeConstraintType(type) === 'check' && constraintName) {
const nameMatch = constraintName.match(/_(\w+)_not_null$/i);
if (nameMatch?.[1]) {
const column = nameMatch[1];
return {
columns: [column],
definition: normalizedDefinition || `${column} IS NOT NULL`,
};
}
}
return { columns: [], definition: normalizedDefinition };
}
export function mapConstraintFromApi(
con: ConstraintInfo,
): TableConstraintDefinition {
const type = normalizeConstraintType(con.constraint_type || 'check');
const actions =
type === 'foreign'
? parseForeignKeyActions(con.definition)
: { onDelete: '', onUpdate: '' };
if (type === 'foreign') {
const fkFromDef = parseForeignKeyFromDefinition(con.definition);
const apiColumns = con.columns?.split(', ').filter(Boolean) || [];
const apiReferencedColumns =
con.referenced_columns?.split(', ').filter(Boolean) || [];
let columns = fkFromDef.columns.length ? fkFromDef.columns : apiColumns;
let referencedTable = fkFromDef.referencedTable || con.referenced_table || '';
let referencedColumns = fkFromDef.referencedColumns.length
? fkFromDef.referencedColumns
: apiReferencedColumns;
if (!columns.length) {
const inferred = inferForeignKeyLocalColumnFromName(con.constraint_name);
if (inferred) {
columns = [inferred];
}
}
return {
name: con.constraint_name,
type,
definition: con.definition || '',
columns,
referencedTable,
referencedColumns,
onDelete: actions.onDelete,
onUpdate: actions.onUpdate,
};
}
const apiColumns = con.columns?.split(', ').filter(Boolean) || [];
const parsed = parseConstraintColumnsFromApi(
type,
con.definition,
con.constraint_name,
apiColumns,
);
return {
name: con.constraint_name,
type,
definition: parsed.definition,
columns: parsed.columns,
referencedTable: con.referenced_table || '',
referencedColumns:
con.referenced_columns?.split(', ').filter(Boolean) || [],
onDelete: actions.onDelete,
onUpdate: actions.onUpdate,
};
}
function quoteIdentifier(name: string, dbType: string): string {
if (dbType === 'mysql') {
return `\`${name}\``;
}
if (dbType === 'sqlserver' || dbType === 'sql server') {
return `[${name}]`;
}
if (dbType === 'oracle' || dbType === 'postgresql') {
return `"${name.replace(/"/g, '""')}"`;
}
return `"${name}"`;
}
function quoteTableRef(
tableName: string,
schema: string | undefined,
dbType: string,
): string {
if (tableName.includes('.')) {
const [schemaName, pureTableName] = tableName.split('.', 2);
return `${quoteIdentifier(schemaName, dbType)}.${quoteIdentifier(pureTableName, dbType)}`;
}
if (schema) {
return `${quoteIdentifier(schema, dbType)}.${quoteIdentifier(tableName, dbType)}`;
}
return quoteIdentifier(tableName, dbType);
}
function quoteColumnList(columns: string[] | undefined, dbType: string): string {
return (columns || [])
.map((column) => quoteIdentifier(column, dbType))
.join(', ');
}
export function buildCreateTableConstraintSql(
constraint: TableConstraintDefinition,
dbType: string,
schema?: string,
): string {
const type = normalizeConstraintType(constraint.type);
const columns = quoteColumnList(constraint.columns, dbType);
let sql = ` CONSTRAINT ${quoteIdentifier(constraint.name, dbType)}`;
switch (type) {
case 'primary': {
sql += ` PRIMARY KEY (${columns})`;
break;
}
case 'foreign': {
const refTable = quoteTableRef(
constraint.referencedTable || '',
schema,
dbType,
);
const refColumns = quoteColumnList(constraint.referencedColumns, dbType);
sql += ` FOREIGN KEY (${columns}) REFERENCES ${refTable} (${refColumns})${buildForeignKeyActionsSql(constraint, dbType)}`;
break;
}
case 'unique': {
sql += ` UNIQUE (${columns})`;
break;
}
case 'check': {
sql += ` CHECK (${constraint.definition || ''})`;
break;
}
default: {
return '';
}
}
return sql;
}
export function buildAddConstraintSql(
constraint: TableConstraintDefinition,
tableRef: string,
dbType: string,
schema?: string,
): string {
const type = normalizeConstraintType(constraint.type);
const columns = quoteColumnList(constraint.columns, dbType);
let sql = `ALTER TABLE ${tableRef} ADD CONSTRAINT ${quoteIdentifier(constraint.name, dbType)}`;
switch (type) {
case 'primary': {
sql += ` PRIMARY KEY (${columns})`;
break;
}
case 'foreign': {
const refTable = quoteTableRef(
constraint.referencedTable || '',
schema,
dbType,
);
const refColumns = quoteColumnList(constraint.referencedColumns, dbType);
sql += ` FOREIGN KEY (${columns}) REFERENCES ${refTable} (${refColumns})${buildForeignKeyActionsSql(constraint, dbType)}`;
break;
}
case 'unique': {
sql += ` UNIQUE (${columns})`;
break;
}
case 'check': {
sql += ` CHECK (${constraint.definition || ''})`;
break;
}
default: {
break;
}
}
return `${sql};`;
}
export function buildDropConstraintSql(
constraint: TableConstraintDefinition,
tableRef: string,
dbType: string,
): string {
const name = quoteIdentifier(constraint.name, dbType);
if (dbType === 'mysql' && normalizeConstraintType(constraint.type) === 'foreign') {
return `ALTER TABLE ${tableRef} DROP FOREIGN KEY ${name};`;
}
return `ALTER TABLE ${tableRef} DROP CONSTRAINT ${name};`;
}
function normalizeConstraintForCompare(
constraint: TableConstraintDefinition,
): TableConstraintDefinition {
return {
name: constraint.name.trim(),
type: normalizeConstraintType(constraint.type),
definition: constraint.definition?.trim() || '',
columns: [...(constraint.columns || [])],
referencedTable: constraint.referencedTable?.trim() || '',
referencedColumns: [...(constraint.referencedColumns || [])],
onDelete: normalizeForeignKeyAction(constraint.onDelete),
onUpdate: normalizeForeignKeyAction(constraint.onUpdate),
};
}
export function constraintEquals(
left: TableConstraintDefinition,
right: TableConstraintDefinition,
): boolean {
return (
JSON.stringify(normalizeConstraintForCompare(left)) ===
JSON.stringify(normalizeConstraintForCompare(right))
);
}
export function buildConstraintAlterSql(
originalConstraints: TableConstraintDefinition[],
currentConstraints: TableConstraintDefinition[],
tableRef: string,
dbType: string,
schema?: string,
): string[] {
const statements: string[] = [];
const originalMap = new Map(
originalConstraints.map((item) => [item.name, item]),
);
const currentMap = new Map(currentConstraints.map((item) => [item.name, item]));
for (const original of originalConstraints) {
const current = currentMap.get(original.name);
if (!current) {
statements.push(buildDropConstraintSql(original, tableRef, dbType));
continue;
}
if (!constraintEquals(original, current)) {
statements.push(buildDropConstraintSql(original, tableRef, dbType));
statements.push(buildAddConstraintSql(current, tableRef, dbType, schema));
}
}
for (const current of currentConstraints) {
if (!originalMap.has(current.name)) {
statements.push(buildAddConstraintSql(current, tableRef, dbType, schema));
}
}
return statements;
}
export interface ConstraintValidateResult {
valid: boolean;
errorKey?:
| 'constraintNameRequired'
| 'constraintColumnsRequired'
| 'constraintCheckDefinitionRequired'
| 'constraintReferencedTableRequired'
| 'constraintReferencedColumnsRequired'
| 'constraintReferencedColumnsMismatch';
}
export function validateConstraints(
constraints: TableConstraintDefinition[],
): ConstraintValidateResult {
for (const constraint of constraints) {
if (!constraint.name?.trim()) {
return { valid: false, errorKey: 'constraintNameRequired' };
}
if (!constraint.columns?.length) {
return { valid: false, errorKey: 'constraintColumnsRequired' };
}
const type = normalizeConstraintType(constraint.type);
if (type === 'check' && !constraint.definition?.trim()) {
return { valid: false, errorKey: 'constraintCheckDefinitionRequired' };
}
if (type === 'foreign') {
if (!constraint.referencedTable?.trim()) {
return { valid: false, errorKey: 'constraintReferencedTableRequired' };
}
if (!constraint.referencedColumns?.length) {
return {
valid: false,
errorKey: 'constraintReferencedColumnsRequired',
};
}
if (constraint.columns.length !== constraint.referencedColumns.length) {
return {
valid: false,
errorKey: 'constraintReferencedColumnsMismatch',
};
}
}
}
return { valid: true };
}
@@ -0,0 +1,48 @@
import type { TreeNodeType } from './types';
/**
* 数据库树节点图标配置
*/
import {
Database,
Eye,
Layers,
Table,
TableProperties,
} from '@vben/icons';
// 节点图标映射(connection 使用 assets/svg 中的数据库类型图标)
const iconMap: Record<TreeNodeType, any> = {
connection: Database,
database: Database,
schema: Layers,
'tables-folder': TableProperties,
'views-folder': Eye,
table: Table,
view: Eye,
};
// 节点图标样式映射
const iconClassMap: Record<TreeNodeType, string> = {
connection: '',
database: 'text-green-500',
schema: 'text-purple-500',
'tables-folder': 'text-orange-500',
'views-folder': 'text-cyan-500',
table: 'text-muted-foreground',
view: 'text-muted-foreground',
};
/**
* 获取节点图标组件
*/
export function getNodeIcon(type: string | TreeNodeType): any {
return iconMap[type as TreeNodeType] || Database;
}
/**
* 获取节点图标样式类
*/
export function getNodeIconClass(type: string | TreeNodeType): string {
return iconClassMap[type as TreeNodeType] || 'text-muted-foreground';
}
@@ -0,0 +1,8 @@
/**
* 数据库树公共工具
*/
export * from './icons';
export * from './types';
export { getDatabaseTypeIcon } from '#/assets/svg';
@@ -0,0 +1,53 @@
/**
* 数据库树节点类型定义
*/
// 节点类型
export type TreeNodeType =
| 'connection'
| 'database'
| 'schema'
| 'table'
| 'tables-folder'
| 'view'
| 'views-folder';
// 节点元数据
export interface TreeNodeMeta {
dbName?: string;
dbType?: string;
database?: string;
isSystem?: boolean;
schema?: string;
table?: string;
view?: string;
}
// 树节点
export interface TreeNode {
id: string;
label: string;
type: TreeNodeType;
isLeaf: boolean;
meta?: TreeNodeMeta;
children?: TreeNode[];
}
// 表字段信息
export interface TableField {
name: string;
type: string;
comment: string;
nullable: boolean;
isPrimaryKey: boolean;
}
// 右键菜单项
export interface ContextMenuItem {
label: string;
icon: any;
action: () => void;
divided?: boolean;
danger?: boolean;
disabled?: boolean;
}
@@ -0,0 +1,393 @@
import { computed } from 'vue';
import { $t } from '@vben/locales';
// 数据类型定义
export interface DataTypeOption {
label: string;
value: string;
hasLength?: boolean;
hasPrecision?: boolean;
desc: string;
}
/**
* 通用数据类型列表
*
* 前端统一使用通用类型,后端根据数据库类型自动转换:
* - varchar: PostgreSQL=VARCHAR, MySQL=VARCHAR, SQL Server=VARCHAR
* - text: PostgreSQL=TEXT, MySQL=TEXT, SQL Server=TEXT
* - int: PostgreSQL=INTEGER, MySQL=INT, SQL Server=INT
* - bigint: PostgreSQL=BIGINT, MySQL=BIGINT, SQL Server=BIGINT
* - smallint: PostgreSQL=SMALLINT, MySQL=SMALLINT, SQL Server=SMALLINT
* - decimal: PostgreSQL=DECIMAL, MySQL=DECIMAL, SQL Server=DECIMAL
* - float: PostgreSQL=REAL, MySQL=FLOAT, SQL Server=FLOAT
* - double: PostgreSQL=DOUBLE PRECISION, MySQL=DOUBLE, SQL Server=FLOAT
* - datetime: PostgreSQL=TIMESTAMP, MySQL=DATETIME, SQL Server=DATETIME2
* - date: PostgreSQL=DATE, MySQL=DATE, SQL Server=DATE
* - time: PostgreSQL=TIME, MySQL=TIME, SQL Server=TIME
* - boolean: PostgreSQL=BOOLEAN, MySQL=TINYINT(1), SQL Server=BIT
* - json: PostgreSQL=JSON, MySQL=JSON, SQL Server=NVARCHAR(MAX)
*/
export const commonDataTypes = computed<DataTypeOption[]>(() => [
{
label: 'VARCHAR',
value: 'varchar',
hasLength: true,
desc: $t('database-manager.dataTypes.varchar'),
},
{
label: 'CHAR',
value: 'char',
hasLength: true,
desc: $t('database-manager.dataTypes.char'),
},
{ label: 'TEXT', value: 'text', desc: $t('database-manager.dataTypes.text') },
{ label: 'INT', value: 'int', desc: $t('database-manager.dataTypes.int') },
{
label: 'BIGINT',
value: 'bigint',
desc: $t('database-manager.dataTypes.bigint'),
},
{
label: 'SMALLINT',
value: 'smallint',
desc: $t('database-manager.dataTypes.smallint'),
},
{
label: 'DECIMAL',
value: 'decimal',
hasPrecision: true,
desc: $t('database-manager.dataTypes.decimal'),
},
{
label: 'NUMERIC',
value: 'numeric',
hasPrecision: true,
desc: $t('database-manager.dataTypes.numeric'),
},
{
label: 'FLOAT',
value: 'float',
desc: $t('database-manager.dataTypes.float'),
},
{
label: 'DOUBLE',
value: 'double',
desc: $t('database-manager.dataTypes.double'),
},
{
label: 'DATETIME',
value: 'datetime',
desc: $t('database-manager.dataTypes.datetime'),
},
{ label: 'DATE', value: 'date', desc: $t('database-manager.dataTypes.date') },
{ label: 'TIME', value: 'time', desc: $t('database-manager.dataTypes.time') },
{
label: 'BOOLEAN',
value: 'boolean',
desc: $t('database-manager.dataTypes.boolean'),
},
{ label: 'JSON', value: 'json', desc: $t('database-manager.dataTypes.json') },
]);
// 保留旧的类型列表以兼容现有代码(已弃用,建议使用 commonDataTypes
export const postgresqlTypes = commonDataTypes;
export const mysqlTypes = commonDataTypes;
export const sqlserverTypes = commonDataTypes;
// 根据数据库类型获取数据类型列表(统一返回通用类型)
export function getDataTypesByDbType(_dbType: string): DataTypeOption[] {
// 统一使用通用类型,后端会根据数据库类型自动转换
return commonDataTypes.value;
}
// 判断字段类型是否需要长度
export function typeHasLength(types: DataTypeOption[], type: string): boolean {
const typeOption = types.find((t) => t.value === type);
return typeOption?.hasLength || false;
}
// 判断字段类型是否需要精度(小数位)
export function typeHasPrecision(
types: DataTypeOption[],
type: string,
): boolean {
const typeOption = types.find((t) => t.value === type);
return typeOption?.hasPrecision || false;
}
// NUMERIC 类型默认值
export const NUMERIC_DEFAULT_LENGTH = 10;
export const NUMERIC_DEFAULT_SCALE = 2;
export interface DbColumnLike {
column_name: string;
data_type: string;
character_maximum_length?: null | number;
numeric_precision?: null | number;
numeric_scale?: null | number;
is_nullable: boolean;
column_default?: null | string;
is_primary_key: boolean;
is_unique: boolean;
description?: null | string;
}
export interface MappedTableField {
name: string;
type: string;
length?: number;
precision?: number;
scale?: number;
nullable: boolean;
default?: string;
primaryKey: boolean;
unique: boolean;
comment?: string;
}
/** 将 SQL Server INFORMATION_SCHEMA 默认值格式规范化为可读形式,如 ((0)) -> 0 */
export function normalizeColumnDefault(
defaultValue?: null | string,
dbType?: string,
): string | undefined {
if (defaultValue == null || defaultValue === '') {
return undefined;
}
const db = (dbType || '').toLowerCase();
if (db !== 'sqlserver' && db !== 'mssql' && db !== 'sql server') {
return defaultValue.trim() || undefined;
}
let value = defaultValue.trim();
while (value.startsWith('(') && value.endsWith(')')) {
value = value.slice(1, -1).trim();
}
if (!value || value.toUpperCase() === 'NULL') {
return undefined;
}
return value;
}
/** 将数据库列信息映射为表设计器字段(含 NUMERIC 精度兼容) */
export function mapDbColumnToField(
col: DbColumnLike,
dbType?: string,
): MappedTableField {
const type = normalizeDbType(col.data_type);
let length = col.character_maximum_length ?? undefined;
let precision = col.numeric_precision ?? undefined;
let scale = col.numeric_scale ?? undefined;
// 兼容旧版 UI 误将精度写入 length 的情况
if (
(type === 'numeric' || type === 'decimal') &&
precision == null &&
length != null
) {
precision = length;
length = undefined;
}
return {
name: col.column_name,
type,
length,
precision,
scale,
nullable: col.is_nullable,
default: normalizeColumnDefault(col.column_default, dbType),
primaryKey: col.is_primary_key,
unique: col.is_unique,
comment: col.description ?? undefined,
};
}
/**
* 数据库返回类型到通用类型的映射表
* 用于将数据库返回的原生类型(如 INTEGER, TIMESTAMP)标准化为前端通用类型(如 int, datetime
*/
const DB_TYPE_TO_COMMON_TYPE: Record<string, string> = {
// PostgreSQL 类型映射
integer: 'int',
int4: 'int',
int8: 'bigint',
int2: 'smallint',
'double precision': 'double',
float4: 'float',
float8: 'double',
real: 'float',
timestamp: 'datetime',
'timestamp without time zone': 'datetime',
'timestamp with time zone': 'datetime',
timestamptz: 'datetime',
bool: 'boolean',
jsonb: 'json',
'character varying': 'varchar',
character: 'char',
// MySQL 类型映射
tinyint: 'boolean',
'tinyint(1)': 'boolean',
// SQL Server 类型映射
datetime2: 'datetime',
bit: 'boolean',
'nvarchar(max)': 'json',
nvarchar: 'varchar',
nchar: 'char',
// Oracle 类型映射
varchar2: 'varchar',
nvarchar2: 'varchar',
number: 'numeric',
clob: 'text',
nclob: 'text',
blob: 'binary',
binary_float: 'float',
binary_double: 'double',
'timestamp(6)': 'datetime',
};
/**
* 将数据库返回的类型标准化为前端通用类型
* @param dbType 数据库返回的原生类型(如 INTEGER, TIMESTAMP, varchar(255)
* @returns 标准化后的通用类型(如 int, datetime, varchar
*/
export function normalizeDbType(dbType: string): string {
if (!dbType) return 'varchar';
// 转小写并去除首尾空格
const lowerType = dbType.toLowerCase().trim();
// 先尝试直接匹配
if (DB_TYPE_TO_COMMON_TYPE[lowerType]) {
return DB_TYPE_TO_COMMON_TYPE[lowerType];
}
// 处理带长度的类型,如 varchar(255) -> varchar, numeric(10,2) -> numeric
const baseType = lowerType.replace(/\(.*\)$/, '').trim();
// 再次尝试匹配基础类型
if (DB_TYPE_TO_COMMON_TYPE[baseType]) {
return DB_TYPE_TO_COMMON_TYPE[baseType];
}
// 检查是否已经是通用类型
const commonTypeValues = commonDataTypes.value.map((t) => t.value);
if (commonTypeValues.includes(baseType)) {
return baseType;
}
// 默认返回原类型(小写)
return baseType;
}
/**
* 通用类型到数据库特定类型的映射表
* 用于生成 SQL 时将通用类型(如 int, datetime)转换为数据库特定类型(如 INTEGER, TIMESTAMP
*/
const COMMON_TYPE_TO_DB_TYPE: Record<string, Record<string, string>> = {
postgresql: {
int: 'INTEGER',
bigint: 'BIGINT',
smallint: 'SMALLINT',
float: 'REAL',
double: 'DOUBLE PRECISION',
datetime: 'TIMESTAMP',
boolean: 'BOOLEAN',
json: 'JSON',
varchar: 'VARCHAR',
char: 'CHAR',
text: 'TEXT',
decimal: 'DECIMAL',
numeric: 'NUMERIC',
date: 'DATE',
time: 'TIME',
},
mysql: {
int: 'INT',
bigint: 'BIGINT',
smallint: 'SMALLINT',
float: 'FLOAT',
double: 'DOUBLE',
datetime: 'DATETIME',
boolean: 'TINYINT(1)',
json: 'JSON',
varchar: 'VARCHAR',
char: 'CHAR',
text: 'TEXT',
decimal: 'DECIMAL',
numeric: 'DECIMAL',
date: 'DATE',
time: 'TIME',
},
sqlserver: {
int: 'INT',
bigint: 'BIGINT',
smallint: 'SMALLINT',
float: 'FLOAT',
double: 'FLOAT',
datetime: 'DATETIME2',
boolean: 'BIT',
json: 'NVARCHAR(MAX)',
// 中文等 Unicode 必须用 N* 类型,VARCHAR/CHAR/TEXT 会落成问号
varchar: 'NVARCHAR',
char: 'NCHAR',
text: 'NVARCHAR(MAX)',
decimal: 'DECIMAL',
numeric: 'NUMERIC',
date: 'DATE',
time: 'TIME',
},
oracle: {
int: 'NUMBER',
bigint: 'NUMBER',
smallint: 'NUMBER',
float: 'BINARY_FLOAT',
double: 'BINARY_DOUBLE',
datetime: 'TIMESTAMP',
boolean: 'NUMBER(1)',
json: 'CLOB',
varchar: 'VARCHAR2',
char: 'CHAR',
text: 'CLOB',
decimal: 'NUMBER',
numeric: 'NUMBER',
date: 'DATE',
time: 'TIMESTAMP',
},
};
/**
* 将通用类型转换为数据库特定类型(用于生成 SQL)
* @param commonType 通用类型(如 int, datetime, varchar
* @param dbType 数据库类型(postgresql, mysql, sqlserver, oracle
* @returns 数据库特定类型(如 INTEGER, TIMESTAMP, VARCHAR
*/
export function mapToDbType(commonType: string, dbType: string): string {
if (!commonType) return 'VARCHAR';
const lowerCommonType = commonType.toLowerCase().trim();
let lowerDbType = dbType.toLowerCase().trim();
if (lowerDbType === 'mssql' || lowerDbType === 'sql server') {
lowerDbType = 'sqlserver';
}
// 获取对应数据库的映射表,默认使用 PostgreSQL
const typeMap: Record<string, string> =
COMMON_TYPE_TO_DB_TYPE[lowerDbType] ?? COMMON_TYPE_TO_DB_TYPE.postgresql!;
// 查找映射
const mappedType = typeMap[lowerCommonType];
if (mappedType) {
return mappedType;
}
// 如果没有映射,返回原类型的大写形式
return commonType.toUpperCase();
}
@@ -0,0 +1,73 @@
/** 字段名 code 规范:字母或下划线开头,仅含字母、数字、下划线 */
export const FIELD_NAME_CODE_PATTERN = /^[a-zA-Z_]\w*$/;
export type FieldNameErrorKey =
| 'fieldNameDuplicate'
| 'fieldNameInvalidFormat'
| 'fieldNameRequired';
export interface FieldNameValidateResult {
valid: boolean;
errorKey?: FieldNameErrorKey;
duplicateName?: string;
}
/** 规范化字段名:去除空格与非法字符,数字开头时补下划线 */
export function sanitizeFieldNameCode(name: string): string {
let result = name.replaceAll(/\s/g, '').replaceAll(/[^\w]/g, '');
if (result && /^\d/.test(result)) {
result = `_${result}`;
}
return result;
}
/** 失焦时规范化:若仅含非法字符则保留原值,避免被清空后误报「不能为空」 */
export function normalizeFieldNameOnBlur(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
return '';
}
const sanitized = sanitizeFieldNameCode(name);
if (!sanitized) {
return trimmed;
}
return sanitized;
}
/** 校验单个字段名是否符合 code 规范 */
export function validateFieldNameCode(name: string): FieldNameValidateResult {
const trimmed = name.trim();
if (!trimmed) {
return { valid: false, errorKey: 'fieldNameRequired' };
}
if (!FIELD_NAME_CODE_PATTERN.test(trimmed)) {
return { valid: false, errorKey: 'fieldNameInvalidFormat' };
}
return { valid: true };
}
/** 校验字段名列表(格式 + 重复) */
export function validateFieldNameList(names: string[]): FieldNameValidateResult {
const seen = new Set<string>();
for (const rawName of names) {
const result = validateFieldNameCode(rawName);
if (!result.valid) {
return result;
}
const name = rawName.trim();
if (seen.has(name)) {
return {
valid: false,
errorKey: 'fieldNameDuplicate',
duplicateName: name,
};
}
seen.add(name);
}
return { valid: true };
}
@@ -0,0 +1,140 @@
import type { FormDataFilter } from '#/components/form-design/store/formDesignStore';
import { getFormDataListApi } from '#/api/online-dev/form-data-api';
/** 根据 formFilters 与当前表单值构建列表 API 查询参数 */
export function buildFormDataFilterParams(
filters: FormDataFilter[],
modelValue: Record<string, any>,
): Record<string, any> {
const params: Record<string, any> = {};
for (const filter of filters) {
if (!filter.sourceField || !filter.targetField) continue;
const sourceValue = modelValue[filter.sourceField];
if (filter.filterType === 'null') {
params[`${filter.targetField}__null`] = 'true';
continue;
}
if (filter.filterType === 'not_null') {
params[`${filter.targetField}__not_null`] = 'true';
continue;
}
if (
sourceValue === undefined ||
sourceValue === null ||
sourceValue === ''
) {
continue;
}
switch (filter.filterType) {
case 'eq': {
params[filter.targetField] = sourceValue;
break;
}
case 'gt': {
params[`${filter.targetField}__gt`] = sourceValue;
break;
}
case 'gte': {
params[`${filter.targetField}__gte`] = sourceValue;
break;
}
case 'in': {
const values = Array.isArray(sourceValue)
? sourceValue.join(',')
: sourceValue;
params[`filter_${filter.targetField}`] = values;
break;
}
case 'like': {
params[`${filter.targetField}__like`] = sourceValue;
break;
}
case 'lt': {
params[`${filter.targetField}__lt`] = sourceValue;
break;
}
case 'lte': {
params[`${filter.targetField}__lte`] = sourceValue;
break;
}
case 'ne': {
params[`${filter.targetField}__ne`] = sourceValue;
break;
}
default: {
break;
}
}
}
return params;
}
/** 将 formFilters 转为 FormDataList.initialFilters 格式 */
export function buildFormSelectorInitialFilters(
filters: FormDataFilter[],
modelValue: Record<string, any>,
): Record<string, { type: string; value: any }> {
const result: Record<string, { type: string; value: any }> = {};
for (const filter of filters) {
if (!filter.sourceField || !filter.targetField) continue;
const sourceValue = modelValue[filter.sourceField];
if (filter.filterType === 'null') {
result[filter.targetField] = { type: 'null', value: true };
continue;
}
if (filter.filterType === 'not_null') {
result[filter.targetField] = { type: 'not_null', value: true };
continue;
}
if (
sourceValue === undefined ||
sourceValue === null ||
sourceValue === ''
) {
continue;
}
const type =
filter.filterType === 'in' ? 'in' : filter.filterType || 'eq';
result[filter.targetField] = { type, value: sourceValue };
}
return result;
}
/** 分页拉取全部表单数据(用于一对多关联自动填充) */
export async function fetchAllFormDataPages(
formCode: string,
filterParams: Record<string, any>,
pageSize = 500,
): Promise<Record<string, any>[]> {
const allItems: Record<string, any>[] = [];
let page = 1;
let total = Number.POSITIVE_INFINITY;
while (allItems.length < total) {
const res = await getFormDataListApi(formCode, {
...filterParams,
page,
pageSize,
});
const items = res?.items || [];
total = res?.total ?? items.length;
allItems.push(...items);
if (items.length < pageSize) break;
page += 1;
}
return allItems;
}
@@ -0,0 +1,531 @@
import type {
DataSourceParamConfig,
FormLifecycleHook,
FormLifecycleMode,
} from '#/components/form-design/store/formDesignStore';
import { ElMessage } from 'element-plus';
import { getWorkflowDetailApi, runWorkflowApi } from '#/api/ai-platform/ai-platform';
import { requestClient } from '#/api/request';
export interface FormLifecycleContext {
formCode: string;
formData: Record<string, any>;
editId?: string;
routeQuery?: Record<string, any>;
savedId?: string;
error?: any;
payload?: { main: Record<string, any>; sub_tables?: Record<string, any> };
response?: any;
}
export interface FormLifecycleResult {
blocked: boolean;
}
export interface FormLoadPipelineOptions {
hooks?: FormLifecycleHook[];
mode: FormLifecycleMode;
formCode: string;
formData: Record<string, any>;
editId?: string;
routeQuery?: Record<string, any>;
initForm: () => void;
applyDefaults?: () => void;
fetchDetail?: () => Promise<void>;
onBlocked: () => void;
}
const WORKFLOW_TYPES = ['data_process', 'automation'] as const;
const BLOCKING_EVENTS = new Set<FormLifecycleHook['event']>([
'beforeLoad',
'beforeSubmit',
]);
function isBlockingEvent(event: FormLifecycleHook['event']) {
return BLOCKING_EVENTS.has(event);
}
export function replaceLifecycleVariables(
template: string,
formData: Record<string, any>,
savedId?: string,
): string {
if (!template) return '';
let result = template.replaceAll(/\{id\}/g, savedId ? String(savedId) : '{id}');
result = result.replaceAll(/\{(\w+)\}/g, (match, key) => {
if (key === 'id') {
return savedId === undefined ? match : String(savedId);
}
return formData[key] === undefined ? match : String(formData[key]);
});
return result;
}
function createScriptHelpers(rootData: Record<string, any>) {
const $setValue = (field: string, value: any) => {
if (field.includes('.')) {
const [table, subField] = field.split('.');
if (Array.isArray(rootData[table!])) {
rootData[table!].forEach((row: any) => {
row[subField!] = value;
});
}
} else {
rootData[field] = value;
}
};
const $setValues = (obj: Record<string, any>) => {
for (const [field, value] of Object.entries(obj)) {
$setValue(field, value);
}
};
const $getValue = (field: string) => {
if (field.includes('.')) {
const [table, subField] = field.split('.');
if (Array.isArray(rootData[table!])) {
return rootData[table!].map((row: any) => row[subField!]);
}
return undefined;
}
return rootData[field];
};
const $getSubTable = (field: string) => rootData[field] || [];
const $setSubTable = (field: string, rows: any[]) => {
rootData[field] = rows;
};
const $addRow = (field: string, row: any) => {
if (!Array.isArray(rootData[field])) {
rootData[field] = [];
}
rootData[field].push({ _id: `${Date.now()}_${Math.random()}`, ...row });
};
const $removeRow = (field: string, index: number) => {
if (Array.isArray(rootData[field])) {
rootData[field].splice(index, 1);
}
};
const $updateRow = (field: string, index: number, data: any) => {
if (Array.isArray(rootData[field]) && rootData[field][index]) {
Object.assign(rootData[field][index], data);
}
};
const $clearSubTable = (field: string) => {
rootData[field] = [];
};
return {
$setValue,
$setValues,
$getValue,
$getSubTable,
$setSubTable,
$addRow,
$removeRow,
$updateRow,
$clearSubTable,
};
}
function buildDataSourceParams(
params: DataSourceParamConfig[] | undefined,
formData: Record<string, any>,
): Record<string, any> {
const result: Record<string, any> = {};
for (const p of params || []) {
if (p.valueSource === 'fixed') {
result[p.name] = p.fixedValue ?? p.default ?? '';
} else if (p.valueSource === 'field' && p.sourceField) {
result[p.name] = formData[p.sourceField] ?? '';
}
}
return result;
}
function buildWorkflowInputs(
hook: FormLifecycleHook,
context: FormLifecycleContext,
): Record<string, any> {
const inputs: Record<string, any> = {
form_code: context.formCode,
};
const dataId = context.savedId || context.editId;
if (dataId) {
inputs.form_data_id = dataId;
}
for (const mapping of hook.actionConfig.workflowInputs || []) {
if (mapping.valueSource === 'field' && mapping.sourceField) {
inputs[mapping.name] = context.formData[mapping.sourceField] ?? '';
} else {
inputs[mapping.name] = mapping.fixedValue ?? mapping.default ?? '';
}
}
return inputs;
}
function appendLoadScriptArgs(
hook: FormLifecycleHook,
context: FormLifecycleContext,
argNames: string[],
argValues: any[],
) {
if (hook.event === 'beforeLoad' || hook.event === 'afterLoadSuccess') {
argNames.push('$editId', '$query');
argValues.push(context.editId, context.routeQuery || {});
}
if (hook.event === 'afterLoadSuccess') {
argNames.push('$savedId');
argValues.push(context.savedId || context.editId);
}
if (hook.event === 'afterLoadFail' || hook.event === 'afterSubmitFail') {
argNames.push('$error');
argValues.push(context.error);
}
if (hook.event === 'afterSubmitSuccess') {
argNames.push('$savedId', '$response');
argValues.push(context.savedId, context.response);
}
}
async function executeScriptHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
mode: FormLifecycleMode,
): Promise<boolean | void> {
const script = hook.actionConfig.script;
if (!script?.trim()) return;
const rootData = context.formData;
const helpers = createScriptHelpers(rootData);
const argNames = [
'model',
'$root',
'$mode',
'$event',
'$setValue',
'$setValues',
'$getValue',
'$getSubTable',
'$setSubTable',
'$addRow',
'$removeRow',
'$updateRow',
'$clearSubTable',
];
const argValues: any[] = [
rootData,
rootData,
mode,
hook.event,
helpers.$setValue,
helpers.$setValues,
helpers.$getValue,
helpers.$getSubTable,
helpers.$setSubTable,
helpers.$addRow,
helpers.$removeRow,
helpers.$updateRow,
helpers.$clearSubTable,
];
appendLoadScriptArgs(hook, context, argNames, argValues);
const fn = new Function(...argNames, script);
return fn(...argValues);
}
async function executeDataSourceHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
mode: FormLifecycleMode,
): Promise<any> {
const dsCode = hook.actionConfig.dataSourceCode;
if (!dsCode) return;
const params = buildDataSourceParams(
hook.actionConfig.dataSourceParams,
context.formData,
);
const response = await requestClient.get(
`/api/core/data-source/execute/${dsCode}`,
{ params },
);
const callbackScript = hook.actionConfig.callbackScript;
if (callbackScript?.trim()) {
const rootData = context.formData;
const helpers = createScriptHelpers(rootData);
const argNames = [
'model',
'$root',
'$result',
'$mode',
'$event',
'$setValue',
'$setValues',
'$getValue',
'$getSubTable',
'$setSubTable',
'$addRow',
'$removeRow',
'$updateRow',
'$clearSubTable',
];
const argValues: any[] = [
rootData,
rootData,
response,
mode,
hook.event,
helpers.$setValue,
helpers.$setValues,
helpers.$getValue,
helpers.$getSubTable,
helpers.$setSubTable,
helpers.$addRow,
helpers.$removeRow,
helpers.$updateRow,
helpers.$clearSubTable,
];
appendLoadScriptArgs(hook, context, argNames, argValues);
const fn = new Function(...argNames, callbackScript);
const callbackResult = fn(...argValues);
if (isBlockingEvent(hook.event) && callbackResult === false) {
return false;
}
}
return response;
}
function executeRedirectHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
) {
const savedId = context.savedId || context.editId;
const url = replaceLifecycleVariables(
hook.actionConfig.url || '',
context.formData,
savedId,
);
if (!url) return;
if (hook.actionConfig.openInNewTab) {
window.open(url, '_blank');
} else {
window.location.href = url;
}
}
function executeMessageHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
) {
const savedId = context.savedId || context.editId;
const message = replaceLifecycleVariables(
hook.actionConfig.message || '',
context.formData,
savedId,
);
if (!message) return;
const messageType = hook.actionConfig.messageType || 'success';
ElMessage[messageType](message);
}
async function executeWorkflowHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
) {
const workflowId = hook.actionConfig.workflowId;
if (!workflowId) return;
const run = async () => {
const workflow = await getWorkflowDetailApi(workflowId);
if (
workflow.status !== 'published' ||
!WORKFLOW_TYPES.includes(
workflow.workflow_type as (typeof WORKFLOW_TYPES)[number],
)
) {
throw new Error('Workflow is not published or type not allowed');
}
const inputs = buildWorkflowInputs(hook, context);
const result = await runWorkflowApi(workflowId, inputs);
if (result.status === 'failed') {
throw new Error(result.error_message || 'Workflow execution failed');
}
return result;
};
if (hook.actionConfig.async) {
run().catch((error) => {
console.warn('[FormLifecycleHook] async workflow failed:', error);
});
return;
}
await run();
}
async function executeSingleHook(
hook: FormLifecycleHook,
context: FormLifecycleContext,
mode: FormLifecycleMode,
): Promise<{ blocked: boolean }> {
switch (hook.actionType) {
case 'script': {
const result = await executeScriptHook(hook, context, mode);
if (isBlockingEvent(hook.event) && result === false) {
return { blocked: true };
}
break;
}
case 'dataSource': {
const result = await executeDataSourceHook(hook, context, mode);
if (isBlockingEvent(hook.event) && result === false) {
return { blocked: true };
}
break;
}
case 'redirect': {
executeRedirectHook(hook, context);
if (hook.event === 'beforeLoad') {
return { blocked: true };
}
break;
}
case 'message': {
executeMessageHook(hook, context);
break;
}
case 'workflow': {
await executeWorkflowHook(hook, context);
break;
}
default:
break;
}
return { blocked: false };
}
function getActiveHooks(
hooks: FormLifecycleHook[] | undefined,
event: FormLifecycleHook['event'],
mode: FormLifecycleMode,
): FormLifecycleHook[] {
return (hooks || [])
.filter(
(hook) =>
hook.enabled &&
hook.event === event &&
Array.isArray(hook.modes) &&
hook.modes.includes(mode),
)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
}
function getBlockErrorMessage(event: FormLifecycleHook['event']) {
if (event === 'beforeLoad') {
return '表单加载被阻止';
}
return '提交前校验未通过';
}
export async function executeFormLifecycleHooks(
hooks: FormLifecycleHook[] | undefined,
event: FormLifecycleHook['event'],
mode: FormLifecycleMode,
context: FormLifecycleContext,
): Promise<FormLifecycleResult> {
const activeHooks = getActiveHooks(hooks, event, mode);
for (const hook of activeHooks) {
try {
const result = await executeSingleHook(hook, context, mode);
if (result.blocked) {
return { blocked: true };
}
} catch (error) {
console.warn(`[FormLifecycleHook] ${hook.id} failed:`, error);
if (isBlockingEvent(hook.event) && hook.blockOnError) {
ElMessage.error(
error instanceof Error ? error.message : getBlockErrorMessage(hook.event),
);
return { blocked: true };
}
}
}
return { blocked: false };
}
export async function runFormLoadPipeline(
opts: FormLoadPipelineOptions,
): Promise<{ loaded: boolean }> {
const {
hooks,
mode,
formCode,
formData,
editId,
routeQuery,
initForm,
applyDefaults,
fetchDetail,
onBlocked,
} = opts;
const baseContext: FormLifecycleContext = {
formCode,
formData,
editId,
routeQuery,
};
const beforeResult = await executeFormLifecycleHooks(
hooks,
'beforeLoad',
mode,
baseContext,
);
if (beforeResult.blocked) {
onBlocked();
return { loaded: false };
}
initForm();
applyDefaults?.();
if (fetchDetail) {
try {
await fetchDetail();
} catch (error) {
await executeFormLifecycleHooks(hooks, 'afterLoadFail', mode, {
...baseContext,
error,
});
throw error;
}
}
await executeFormLifecycleHooks(hooks, 'afterLoadSuccess', mode, {
...baseContext,
savedId: editId,
});
return { loaded: true };
}
+217
View File
@@ -0,0 +1,217 @@
import {
quoteIdentifier,
quoteTable,
} from '#/views/_core/database-manager/utils/sql-identifier';
export interface IndexDefinition {
name: string;
type: string;
columns: string[];
unique: boolean;
}
function normalizeDbType(dbType: string): string {
const db = (dbType || 'postgresql').toLowerCase();
if (db === 'sql server' || db === 'mssql') {
return 'sqlserver';
}
return db;
}
/** 将后端返回的 index_type 规范化为前端枚举值 */
export function normalizeIndexType(
indexType: string | undefined,
dbType: string,
): string {
const db = normalizeDbType(dbType);
const raw = (indexType || '').toLowerCase().replaceAll(/\s+/g, '_');
if (db === 'postgresql') {
if (['btree', 'hash', 'gin', 'gist', 'brin'].includes(raw)) {
return raw;
}
return 'btree';
}
if (db === 'mysql') {
if (raw === 'hash') {
return 'hash';
}
return 'btree';
}
if (db === 'sqlserver') {
if (raw.includes('clustered') && !raw.includes('non')) {
return 'clustered';
}
if (raw === 'hash') {
return 'hash';
}
return 'nonclustered';
}
if (db === 'oracle') {
if (raw.includes('bitmap')) {
return 'bitmap';
}
return 'normal';
}
return 'btree';
}
function quoteColumnList(columns: string[], dbType: string): string {
return columns
.map((column) => quoteIdentifier(column, dbType))
.join(', ');
}
function buildSqlServerIndexTypeClause(indexType: string): string {
const type = indexType.toLowerCase();
if (type === 'clustered') {
return 'CLUSTERED ';
}
if (type === 'hash') {
return 'NONCLUSTERED ';
}
return 'NONCLUSTERED ';
}
function buildOracleIndexTypeClause(indexType: string): string {
if (indexType.toLowerCase() === 'bitmap') {
return 'BITMAP ';
}
return '';
}
export function buildCreateIndexSql(
index: IndexDefinition,
tableRef: string,
dbType: string,
_schema?: string,
): string {
const db = normalizeDbType(dbType);
const indexName = quoteIdentifier(index.name, db);
const indexColumns = quoteColumnList(index.columns, db);
const uniquePrefix = index.unique ? 'UNIQUE ' : '';
switch (db) {
case 'mysql': {
const indexKeyword = index.unique ? 'UNIQUE INDEX' : 'INDEX';
return `CREATE ${indexKeyword} ${indexName} ON ${tableRef} (${indexColumns});`;
}
case 'sqlserver': {
const typeClause = buildSqlServerIndexTypeClause(index.type);
return `CREATE ${uniquePrefix}${typeClause}INDEX ${indexName} ON ${tableRef} (${indexColumns});`;
}
case 'oracle': {
const typeClause = buildOracleIndexTypeClause(index.type);
return `CREATE ${uniquePrefix}${typeClause}INDEX ${indexName} ON ${tableRef} (${indexColumns});`;
}
case 'postgresql':
default: {
const method = (index.type || 'btree').toUpperCase();
const indexKeyword = index.unique ? 'UNIQUE INDEX' : 'INDEX';
return `CREATE ${indexKeyword} ${indexName} ON ${tableRef} USING ${method} (${indexColumns});`;
}
}
}
export function buildDropIndexSql(
index: IndexDefinition,
tableRef: string,
dbType: string,
schema?: string,
): string {
const db = normalizeDbType(dbType);
const indexName = quoteIdentifier(index.name, db);
switch (db) {
case 'mysql': {
return `DROP INDEX ${indexName} ON ${tableRef};`;
}
case 'sqlserver': {
return `DROP INDEX ${indexName} ON ${tableRef};`;
}
case 'oracle': {
if (schema) {
return `DROP INDEX ${quoteIdentifier(schema, db)}.${indexName};`;
}
return `DROP INDEX ${indexName};`;
}
case 'postgresql':
default: {
if (schema) {
return `DROP INDEX IF EXISTS ${quoteIdentifier(schema, db)}.${indexName};`;
}
return `DROP INDEX IF EXISTS ${indexName};`;
}
}
}
function normalizeIndexForCompare(index: IndexDefinition): IndexDefinition {
return {
name: index.name.trim(),
type: (index.type || 'btree').toLowerCase(),
columns: [...index.columns],
unique: index.unique,
};
}
export function indexEquals(
left: IndexDefinition,
right: IndexDefinition,
): boolean {
return (
JSON.stringify(normalizeIndexForCompare(left)) ===
JSON.stringify(normalizeIndexForCompare(right))
);
}
export function buildIndexAlterSql(
originalIndexes: IndexDefinition[],
currentIndexes: IndexDefinition[],
tableRef: string,
dbType: string,
schema?: string,
): string[] {
const statements: string[] = [];
const originalMap = new Map(
originalIndexes.map((item) => [item.name, item]),
);
const currentMap = new Map(currentIndexes.map((item) => [item.name, item]));
for (const original of originalIndexes) {
const current = currentMap.get(original.name);
if (!current) {
statements.push(buildDropIndexSql(original, tableRef, dbType, schema));
continue;
}
if (!indexEquals(original, current)) {
statements.push(buildDropIndexSql(original, tableRef, dbType, schema));
statements.push(buildCreateIndexSql(current, tableRef, dbType, schema));
}
}
for (const current of currentIndexes) {
if (!originalMap.has(current.name)) {
statements.push(buildCreateIndexSql(current, tableRef, dbType, schema));
}
}
return statements;
}
/** 解析 API 索引数据为编辑器格式 */
export function mapIndexFromApi(
idx: {
index_name: string;
index_type?: string;
columns?: string;
is_unique?: boolean;
},
dbType: string,
): IndexDefinition {
return {
name: idx.index_name,
type: normalizeIndexType(idx.index_type, dbType),
columns: idx.columns?.split(', ').filter(Boolean) || [],
unique: Boolean(idx.is_unique),
};
}
@@ -0,0 +1,90 @@
import type {
FormItemSchema,
ReverseAutoFillConfig,
} from '#/components/form-design/store/formDesignStore';
export interface ReverseAutoFillContext {
cfg: ReverseAutoFillConfig;
formCode?: string;
kind: 'form-selector' | 'select';
valueField: string;
}
/** 是否可在设计器配置关联自动填充 */
export function canConfigureReverseAutoFill(
item: { dataSource?: { type?: string }; type?: string } | null | undefined,
): boolean {
if (!item) return false;
if (item.type === 'form-selector') return true;
return item.type === 'select' && item.dataSource?.type === 'formData';
}
/** 是否已启用关联自动填充(含未完整配置) */
export function hasReverseAutoFillEnabled(
item: FormItemSchema | null | undefined,
): boolean {
if (!item) return false;
if (item.type === 'form-selector') {
return !!item.formSelectorConfig?.reverseAutoFill?.enabled;
}
if (item.type === 'select' && item.dataSource?.type === 'formData') {
return !!item.dataSource.reverseAutoFill?.enabled;
}
return false;
}
/** 读取关联自动填充配置(设计器用) */
export function getReverseAutoFillConfig(
item: FormItemSchema | null | undefined,
): ReverseAutoFillConfig | undefined {
if (!item) return undefined;
if (item.type === 'form-selector') {
return item.formSelectorConfig?.reverseAutoFill;
}
if (item.type === 'select' && item.dataSource?.type === 'formData') {
return item.dataSource.reverseAutoFill;
}
return undefined;
}
/** 运行时:获取已启用的关联自动填充上下文 */
export function getReverseAutoFillContext(
item: FormItemSchema | null | undefined,
): ReverseAutoFillContext | null {
if (!item) return null;
if (item.type === 'form-selector') {
const cfg = item.formSelectorConfig?.reverseAutoFill;
if (!cfg?.enabled || !cfg.sourceField) return null;
return {
cfg,
formCode: item.formSelectorConfig?.formCode,
valueField: item.formSelectorConfig?.valueField || 'id',
kind: 'form-selector',
};
}
if (item.type === 'select' && item.dataSource?.type === 'formData') {
const cfg = item.dataSource.reverseAutoFill;
if (!cfg?.enabled || !cfg.sourceField) return null;
return {
cfg,
formCode: item.dataSource.formCode,
valueField: item.dataSource.formValueField || 'id',
kind: 'select',
};
}
return null;
}
export function createDefaultReverseAutoFillConfig(): ReverseAutoFillConfig {
return {
enabled: false,
sourceField: '',
targetField: 'b_id',
filterType: 'eq',
pageSize: 500,
clearWhenEmpty: true,
};
}
+48
View File
@@ -0,0 +1,48 @@
import { $t } from '@vben/locales';
/**
* 格式化相对时间
* 显示:刚刚、X分钟前、X小时前、昨天、X天前、具体日期
* @param dateStr 日期字符串
* @returns 格式化后的时间字符串
*/
export function formatRelativeTime(dateStr?: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
// 刚刚(1分钟内)
if (diff < 60_000) {
return $t('common.justNow');
}
// X分钟前(1小时内)
if (diff < 3_600_000) {
const minutes = Math.floor(diff / 60_000);
return `${minutes} ${$t('common.minutesAgo')}`;
}
// X小时前(24小时内)
if (diff < 86_400_000) {
const hours = Math.floor(diff / 3_600_000);
return `${hours} ${$t('common.hoursAgo')}`;
}
// 昨天
const days = Math.floor(diff / 86_400_000);
if (days === 1) {
return $t('common.yesterday');
}
// X天前(7天内)
if (days < 7) {
return `${days} ${$t('common.daysAgo')}`;
}
// 超过7天显示具体日期
return date.toLocaleDateString();
}