Build lightweight AI agent admin
This commit is contained in:
@@ -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 - 渐变优化版本
|
||||
**状态**: 生产就绪
|
||||
@@ -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', // 文字颜色总是白色
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user