diff --git a/app/admin/scheme/TestGoods.php b/app/admin/scheme/TestGoods.php
new file mode 100644
index 0000000..57422bc
--- /dev/null
+++ b/app/admin/scheme/TestGoods.php
@@ -0,0 +1,121 @@
+ 'mall_cate', 'relationBindSelect' => 'title'])]
+ public $cate_id;
+
+ #[Field(type: 'char', length: 20, precision: 20, scale: 0, nullable: false, default: '', comment: '商品名称', unsigned: false, autoIncrement: false, primary: false)]
+ public $title;
+
+ #[Field(type: 'char', length: 255, precision: 255, scale: 0, nullable: false, default: null, comment: '商品logo', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'image', options: [])]
+ public $logo;
+
+ #[Field(type: 'text', length: null, precision: 0, scale: 0, nullable: false, default: null, comment: '商品图片', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'images', options: [])]
+ public $images;
+
+ #[Field(type: 'text', length: null, precision: 0, scale: 0, nullable: false, default: null, comment: '商品描述', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'editor', options: [])]
+ public $describe;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '总库存', unsigned: true, autoIncrement: false, primary: false)]
+ public $total_stock;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '排序', unsigned: true, autoIncrement: false, primary: false)]
+ public $sort;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '状态', unsigned: true, autoIncrement: false, primary: false)]
+ #[Component(type: 'radio', options: ['正常', '禁用'])]
+ public $status;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '合格证', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'file', options: [])]
+ public $cert_file;
+
+ #[Field(type: 'text', length: null, precision: 0, scale: 0, nullable: false, default: null, comment: '检测报告', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'files', options: [])]
+ public $verfiy_file;
+
+ #[Field(type: 'char', length: 255, precision: 255, scale: 0, nullable: false, default: '', comment: '备注说明', unsigned: false, autoIncrement: false, primary: false)]
+ public $remark;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '', unsigned: true, autoIncrement: false, primary: false)]
+ public $create_time;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '', unsigned: true, autoIncrement: false, primary: false)]
+ public $update_time;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '', unsigned: true, autoIncrement: false, primary: false)]
+ public $delete_time;
+
+ #[Field(type: 'datetime', length: null, precision: 0, scale: 0, nullable: false, default: null, comment: '发布日期', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'date', options: ['date'])]
+ public $publish_time;
+
+ #[Field(type: 'date', length: null, precision: 0, scale: 0, nullable: false, default: null, comment: '售卖日期', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'date', options: ['datetime'])]
+ public $sale_time;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '简介', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'textarea', options: [])]
+ public $intro;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: null, comment: '秒杀状态', unsigned: true, autoIncrement: false, primary: false)]
+ #[Component(type: 'select', options: [0 => '未参加', 1 => '已开始', 3 => '已结束'])]
+ public $time_status;
+
+ #[Field(type: 'int', length: 11, precision: 0, scale: 0, nullable: false, default: '0', comment: '是否推荐', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'switch', options: ['不推荐', '推荐'])]
+ public $is_recommend;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: '0', comment: '商品类型', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'checkbox', options: ['taobao' => '淘宝', 'jd' => '京东'])]
+ public $shop_type;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '商品标签', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'checkbox', 'valueField' => 'id', 'fieldName' => 'title'])]
+ public $tag;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: true, default: null, comment: '商品标签(单选)', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'table', options: ['table' => 'mall_tag', 'type' => 'radio', 'valueField' => 'id', 'fieldName' => 'title'])]
+ public $tag_backup;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '产地', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'city', options: ['name-province' => '0', 'code' => '0'])]
+ public $from_area;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: '山东省/临沂市', comment: '仓库', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'city', options: ['level' => 'city'])]
+ public $store_city;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '商品标签 (输入)', unsigned: false, autoIncrement: false, primary: false)]
+ #[Component(type: 'tag', options: [])]
+ public $tag_input;
+
+ #[Field(type: 'varchar', length: 100, precision: 100, scale: 0, nullable: false, default: null, comment: '唯一id', unsigned: false, autoIncrement: false, primary: false)]
+ public $uid;
+
+ #[Field(type: 'decimal', length: 10, precision: 10, scale: 0, nullable: true, default: null, comment: '价格', unsigned: false, autoIncrement: false, primary: false)]
+ public $price;
+
+ #[Field(type: 'text', length: null, precision: 0, scale: 0, nullable: true, default: null, comment: '详情', unsigned: false, autoIncrement: false, primary: false)]
+ public $detail;
+}
\ No newline at end of file
diff --git a/app/common/command/scheme/Backup.php b/app/common/command/scheme/Backup.php
new file mode 100644
index 0000000..1796cc1
--- /dev/null
+++ b/app/common/command/scheme/Backup.php
@@ -0,0 +1,9 @@
+ Env::get('database.type', 'sqlite'),
+ 'default' => Env::get('database.main', 'sqlite'),
// 自定义时间查询规则
'time_query_rule' => [],
@@ -21,7 +21,7 @@ return [
// 数据库连接配置信息
'connections' => [
- 'mysql' => [
+ 'main' => [
// 数据库类型
'type' => Env::get('database.type', 'mysql'),
// 服务器地址
diff --git a/config/scheme.php b/config/scheme.php
new file mode 100644
index 0000000..83d4d06
--- /dev/null
+++ b/config/scheme.php
@@ -0,0 +1,11 @@
+ [
+ 'migrations',
+ 'phinxlog',
+ ],
+ // 备份中间前缀
+ 'backup_prefix' => 'backup',
+];
diff --git a/extend/base/common/command/scheme/Backup.php b/extend/base/common/command/scheme/Backup.php
new file mode 100644
index 0000000..7e55c22
--- /dev/null
+++ b/extend/base/common/command/scheme/Backup.php
@@ -0,0 +1,25 @@
+setName('scheme:backups')
+ ->setDescription('List all backup tables');
+ }
+
+ protected function execute(Input $input, Output $output)
+ {
+ $tables = Db::query("SHOW TABLES LIKE 'ul_backup%'");
+ foreach ($tables as $t) {
+ $output->writeln(array_values($t)[0]);
+ }
+ }
+}
diff --git a/extend/base/common/command/scheme/Make.php b/extend/base/common/command/scheme/Make.php
new file mode 100644
index 0000000..3bb6a26
--- /dev/null
+++ b/extend/base/common/command/scheme/Make.php
@@ -0,0 +1,73 @@
+setName('scheme:make')
+ ->addArgument('table', Argument::OPTIONAL, "The table name (without prefix)")
+ ->setDescription('Generate Scheme class from Database table');
+ }
+
+ protected function execute(Input $input, Output $output)
+ {
+ $table = $input->getArgument('table');
+ $service = new DbToSchemeService();
+
+ $tables = [];
+ if ($table) {
+ $tables[] = $table;
+ } else {
+ // 获取所有表
+ $allTables = Db::getTables();
+ // 过滤掉忽略的表
+ $config = Config::get('scheme.ignore_tables', []);
+ $prefix = Config::get('database.connections.mysql.prefix');
+
+ foreach ($allTables as $t) {
+ // 如果有前缀,去除前缀后再判断
+ $shortName = $t;
+ if ($prefix && str_starts_with($t, $prefix)) {
+ $shortName = substr($t, strlen($prefix));
+ }
+
+ if (!in_array($shortName, $config) && !in_array($t, $config)) {
+ $tables[] = $shortName;
+ }
+ }
+ }
+
+ foreach ($tables as $t) {
+ $output->writeln("Processing table: $t");
+ try {
+ $code = $service->generate($t);
+
+ // 提取类名以确定文件名
+ if (preg_match('/class\s+(\w+)/', $code, $matches)) {
+ $className = $matches[1];
+ $path = app()->getAppPath() . 'admin/scheme/' . $className . '.php';
+
+ // 确保目录存在
+ if (!is_dir(dirname($path))) {
+ mkdir(dirname($path), 0755, true);
+ }
+
+ file_put_contents($path, $code);
+ $output->writeln("Generated: $path");
+ }
+ } catch (\Exception $e) {
+ $output->writeln("Error processing $t: " . $e->getMessage() . "");
+ }
+ }
+ }
+}
diff --git a/extend/base/common/command/scheme/Sync.php b/extend/base/common/command/scheme/Sync.php
new file mode 100644
index 0000000..1baa143
--- /dev/null
+++ b/extend/base/common/command/scheme/Sync.php
@@ -0,0 +1,53 @@
+setName('scheme:sync')
+ ->addOption('skip-data', null, Option::VALUE_NONE, 'Skip data migration')
+ ->addOption('force', null, Option::VALUE_NONE, 'Force execution without confirmation')
+ ->setDescription('Synchronize Scheme classes to Database');
+ }
+
+ protected function execute(Input $input, Output $output)
+ {
+ $skipData = $input->getOption('skip-data');
+
+ $service = new SchemeToDbService();
+ $schemeDir = app()->getAppPath() . 'admin/scheme/';
+
+ if (!is_dir($schemeDir)) {
+ $output->writeln("Scheme directory not found: $schemeDir");
+ return;
+ }
+
+ $files = glob($schemeDir . '*.php');
+ foreach ($files as $file) {
+ require_once $file;
+ $className = 'app\\admin\\scheme\\' . basename($file, '.php');
+
+ if (class_exists($className)) {
+ $output->writeln("Syncing $className...");
+ try {
+ $backup = $service->sync($className, $skipData);
+ $output->writeln("Success!");
+ if ($backup) {
+ $output->writeln("Backup created: $backup");
+ }
+ } catch (\Exception $e) {
+ $output->writeln("Failed: " . $e->getMessage() . "");
+ }
+ }
+ }
+ }
+}
diff --git a/extend/base/common/scheme/BaseScheme.php b/extend/base/common/scheme/BaseScheme.php
new file mode 100644
index 0000000..2a79b71
--- /dev/null
+++ b/extend/base/common/scheme/BaseScheme.php
@@ -0,0 +1,8 @@
+'男', '2'=>'女'])
+ * @param array $extra 额外参数
+ */
+ public function __construct(
+ public string $type,
+ public array $options = [],
+ public array $extra = []
+ ) {
+ }
+}
diff --git a/extend/base/common/scheme/attribute/Field.php b/extend/base/common/scheme/attribute/Field.php
new file mode 100644
index 0000000..ed35610
--- /dev/null
+++ b/extend/base/common/scheme/attribute/Field.php
@@ -0,0 +1,23 @@
+connection = Config::get('database.default', 'mysql');
+ }
+
+ public function generate(string $tableName, string $className = null): string
+ {
+ $prefix = Config::get('database.connections.' . $this->connection . '.prefix', '');
+ $fullTableName = $tableName;
+ // 如果表名不包含前缀,且配置了前缀,则添加
+ if ($prefix && !str_starts_with($tableName, $prefix)) {
+ $fullTableName = $prefix . $tableName;
+ }
+
+ // 尝试获取表信息
+ try {
+ // 获取列信息,TP通用方法
+ $columns = Db::connect($this->connection)->getFields($fullTableName);
+ } catch (\Exception $e) {
+ // 尝试直接使用tableName
+ $fullTableName = $tableName;
+ $columns = Db::connect($this->connection)->getFields($fullTableName);
+ }
+
+ // 获取表注释
+ $tableComment = $this->getTableComment($fullTableName);
+
+ // 获取索引信息
+ $indices = $this->getTableIndices($fullTableName);
+
+ // 生成类名
+ if (empty($className)) {
+ // 去除前缀
+ $shortName = $tableName;
+ if ($prefix && str_starts_with($tableName, $prefix)) {
+ $shortName = substr($tableName, strlen($prefix));
+ }
+ $className = Str::studly($shortName);
+ }
+
+ // 构建类内容
+ return $this->buildClass($className, $fullTableName, $tableComment, $columns, $indices);
+ }
+
+ protected function getTableComment($tableName): string
+ {
+ // 简单适配 MySQL
+ try {
+ $config = Config::get('database.connections.' . $this->connection);
+ $database = $config['database'];
+ // 暂时只支持MySQL的表注释获取,其他返回空
+ $sql = "SELECT table_comment FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?";
+ $res = Db::connect($this->connection)->query($sql, [$database, $tableName]);
+ return $res[0]['table_comment'] ?? '';
+ } catch (\Exception $e) {
+ return '';
+ }
+ }
+
+ protected function getTableIndices($tableName): array
+ {
+ try {
+ // SHOW KEYS FROM table
+ $keys = Db::connect($this->connection)->query("SHOW KEYS FROM `$tableName`");
+ $indices = [];
+ foreach ($keys as $key) {
+ $name = $key['Key_name'];
+ if ($name === 'PRIMARY') continue; // 主键在 Field 中处理
+
+ if (!isset($indices[$name])) {
+ $indices[$name] = [
+ 'name' => $name,
+ 'columns' => [],
+ 'type' => $key['Non_unique'] == 0 ? 'UNIQUE' : ($key['Index_type'] == 'FULLTEXT' ? 'FULLTEXT' : 'NORMAL')
+ ];
+ }
+ $indices[$name]['columns'][] = $key['Column_name'];
+ }
+ return array_values($indices);
+ } catch (\Exception $e) {
+ return [];
+ }
+ }
+
+ protected function buildClass($className, $tableName, $tableComment, $columns, $indices): string
+ {
+ $fieldsCode = [];
+
+ foreach ($columns as $field) {
+ $fieldName = $field['name'];
+ $type = $field['type']; // varchar(255)
+ $comment = $field['comment'] ?? '';
+ $default = $field['default'] ?? null;
+ $notNull = $field['notnull'] ?? false;
+ $primary = $field['primary'] ?? false;
+ $autoinc = $field['autoinc'] ?? false;
+
+ // 解析类型和长度
+ preg_match('/(\w+)(?:\((\d+)(?:,(\d+))?\))?/', $type, $matches);
+ $dbType = $matches[1] ?? 'varchar';
+ $length = isset($matches[2]) ? (int)$matches[2] : null;
+ $precision = isset($matches[2]) ? (int)$matches[2] : 0; // For decimal
+ $scale = isset($matches[3]) ? (int)$matches[3] : 0;
+
+ // 解析 Ulthon 组件语法
+ $componentAttr = '';
+ $cleanComment = $comment;
+
+ // 匹配 {type} (options)
+ // 改进:允许 options 中含有冒号,且 key:value 之间可能有空格,或者没有括号
+ // 例子:{radio} (0:禁用,1:启用) 或 {image} 或 {relation} (table:mall_cate,relationBindSelect:title)
+ if (preg_match('/\{(.*?)\}\s*(\((.*?)\))?/', $comment, $cmatch)) {
+ $compType = $cmatch[1];
+ $compOptionsStr = $cmatch[3] ?? '';
+ $cleanComment = trim(str_replace($cmatch[0], '', $comment));
+
+ // 解析选项 (1:A, 2:B) 或 (table:mall_cate,relationBindSelect:title)
+ $optionsCode = '[]';
+ if ($compOptionsStr) {
+ $options = [];
+ // 简单的 explode 可能有问题,如果值里面有逗号。但目前假设选项格式比较简单。
+ $parts = explode(',', $compOptionsStr);
+ foreach ($parts as $p) {
+ // 尝试分割 key:value
+ $kv = explode(':', trim($p), 2); // Limit to 2 parts
+ if (count($kv) >= 2) {
+ $k = trim($kv[0]);
+ $v = trim($kv[1]);
+ $options[$k] = $v;
+ } else {
+ $options[] = trim($p);
+ }
+ }
+ $optionsCode = $this->exportArray($options);
+ }
+
+ $componentAttr = "\n #[Component(type: '$compType', options: $optionsCode)]";
+ }
+
+ // 构建 Field 注解
+ $defaultStr = is_null($default) ? 'null' : (is_string($default) ? "'$default'" : $default);
+ $nullable = $notNull ? 'false' : 'true';
+ $unsigned = str_contains($type, 'unsigned') ? 'true' : 'false';
+ $primaryStr = $primary ? 'true' : 'false';
+ $autoincStr = $autoinc ? 'true' : 'false';
+
+ // 如果是整型且未指定长度,默认11 (兼容旧习俗)
+ if (str_contains($dbType, 'int') && !$length) {
+ $length = 11;
+ }
+
+ $lengthStr = is_null($length) ? 'null' : $length;
+
+ $fieldsCode[] = <<exportArray($idx['columns']);
+ $indexAttrs .= "\n#[Index(columns: $cols, name: '{$idx['name']}', type: '{$idx['type']}')]";
+ }
+
+ return <<module}\scheme;
+
+use app\common\scheme\BaseScheme;
+use app\common\scheme\attribute\Table;
+use app\common\scheme\attribute\Field;
+use app\common\scheme\attribute\Component;
+use app\common\scheme\attribute\Index;
+
+#[Table(name: '$tableName', comment: '$tableComment')]$indexAttrs
+class $className extends BaseScheme
+{
+$fieldsBody
+}
+PHP;
+ }
+
+ protected function exportArray(array $array): string
+ {
+ // 简易数组导出
+ $items = [];
+ // 判断是否是关联数组
+ $isAssoc = array_keys($array) !== range(0, count($array) - 1);
+
+ foreach ($array as $k => $v) {
+ $val = is_string($v) ? "'$v'" : $v;
+ if ($isAssoc) {
+ $key = is_int($k) ? $k : "'$k'";
+ $items[] = "$key => $val";
+ } else {
+ $items[] = $val;
+ }
+ }
+ return '[' . implode(', ', $items) . ']';
+ }
+}
diff --git a/extend/base/common/service/scheme/SchemeToDbService.php b/extend/base/common/service/scheme/SchemeToDbService.php
new file mode 100644
index 0000000..40960ce
--- /dev/null
+++ b/extend/base/common/service/scheme/SchemeToDbService.php
@@ -0,0 +1,243 @@
+connection = Config::get('database.default', 'mysql');
+ }
+
+ public function sync(string $className, bool $skipData = false)
+ {
+ if (!class_exists($className)) {
+ throw new \Exception("Class $className not found");
+ }
+
+ $ref = new ReflectionClass($className);
+
+ // 获取 Table 注解
+ $tableAttrs = $ref->getAttributes(Table::class);
+ if (empty($tableAttrs)) {
+ throw new \Exception("Class $className missing #[Table] attribute");
+ }
+ /** @var Table $tableAttr */
+ $tableAttr = $tableAttrs[0]->newInstance();
+
+ $tableName = $tableAttr->name;
+ $prefix = Config::get('database.connections.' . $this->connection . '.prefix', '');
+
+ // 确保表名带前缀
+ $fullTableName = $tableName;
+ if ($prefix && !str_starts_with($tableName, $prefix)) {
+ $fullTableName = $prefix . $tableName;
+ }
+
+ // 检查表是否存在
+ $tableExists = $this->checkTableExists($fullTableName);
+ $backupTableName = null;
+
+ // 1. 备份
+ if ($tableExists) {
+ $backupPrefix = Config::get('scheme.backup_prefix', 'backup');
+ $backupTableName = $prefix . $backupPrefix . '_' . date('YmdHis') . '_' . $fullTableName;
+
+ try {
+ // 确保没有残留的同名备份表(极端情况)
+ Db::connect($this->connection)->execute("DROP TABLE IF EXISTS `$backupTableName`");
+ Db::connect($this->connection)->execute("RENAME TABLE `$fullTableName` TO `$backupTableName`");
+ } catch (\Exception $e) {
+ // 如果备份失败,可能是权限问题或其他,抛出异常
+ throw new \Exception("Backup failed: " . $e->getMessage());
+ }
+ }
+
+ // 2. 建表
+ $sql = $this->buildCreateTableSql($fullTableName, $tableAttr, $ref);
+ Db::connect($this->connection)->execute($sql);
+
+ // 3. 恢复数据
+ if ($tableExists && !$skipData && $backupTableName) {
+ try {
+ $this->restoreData($fullTableName, $backupTableName, $ref->getProperties());
+ } catch (\Exception $e) {
+ // 如果数据恢复失败,尝试回滚(这里只是简单的删除新表,重命名回旧表)
+ // 实际生产环境可能需要更复杂的恢复机制
+ // Db::connect($this->connection)->execute("DROP TABLE IF EXISTS `$fullTableName`");
+ // Db::connect($this->connection)->execute("RENAME TABLE `$backupTableName` TO `$fullTableName`");
+ throw $e;
+ }
+ }
+
+ return $backupTableName;
+ }
+
+ protected function checkTableExists($tableName)
+ {
+ $tables = Db::connect($this->connection)->query("SHOW TABLES LIKE '$tableName'");
+ return !empty($tables);
+ }
+
+ protected function buildCreateTableSql($tableName, Table $tableAttr, ReflectionClass $ref)
+ {
+ $properties = $ref->getProperties();
+ $lines = [];
+ $primaryKeys = [];
+
+ foreach ($properties as $prop) {
+ $fieldAttrs = $prop->getAttributes(Field::class);
+ if (empty($fieldAttrs)) {
+ continue;
+ }
+ /** @var Field $field */
+ $field = $fieldAttrs[0]->newInstance();
+ $fieldName = $prop->getName();
+
+ $line = "`$fieldName` {$field->type}";
+
+ // Length/Precision
+ if ($field->length !== null) {
+ $line .= "({$field->length})";
+ } elseif ($field->precision > 0) {
+ $line .= "({$field->precision},{$field->scale})";
+ }
+
+ // Unsigned
+ if ($field->unsigned) {
+ $line .= " UNSIGNED";
+ }
+
+ // Nullable
+ if (!$field->nullable) {
+ $line .= " NOT NULL";
+ } else {
+ $line .= " DEFAULT NULL";
+ }
+
+ // AutoIncrement
+ if ($field->autoIncrement) {
+ $line .= " AUTO_INCREMENT";
+ }
+
+ // Default
+ if (!is_null($field->default)) {
+ $def = $field->default;
+ if (is_string($def)) {
+ $line .= " DEFAULT '$def'";
+ } elseif (is_bool($def)) {
+ $line .= " DEFAULT " . ($def ? 1 : 0);
+ } else {
+ $line .= " DEFAULT $def";
+ }
+ }
+
+ // Comment + Component Restore
+ $comment = $field->comment;
+
+ // 检查是否有 Component 注解
+ $compAttrs = $prop->getAttributes(Component::class);
+ if (!empty($compAttrs)) {
+ /** @var Component $comp */
+ $comp = $compAttrs[0]->newInstance();
+ $typeStr = "{{$comp->type}}";
+ $optionsStr = '';
+ if (!empty($comp->options)) {
+ $parts = [];
+ foreach ($comp->options as $k => $v) {
+ // 如果是数字索引,且连续,可能就是简单的值列表?
+ // 但 Ulthon 格式通常是 k:v。如果 k 是数字,也要输出。
+ $parts[] = "$k:$v";
+ }
+ $optionsStr = ' (' . implode(',', $parts) . ')';
+ }
+ // 拼接回注释:原注释 {type} (options)
+ $comment = trim($comment . " $typeStr$optionsStr");
+ }
+
+ if ($comment) {
+ $line .= " COMMENT '$comment'";
+ }
+
+ $lines[] = $line;
+
+ if ($field->primary) {
+ $primaryKeys[] = "`$fieldName`";
+ }
+ }
+
+ if (!empty($primaryKeys)) {
+ $lines[] = "PRIMARY KEY (" . implode(',', $primaryKeys) . ")";
+ }
+
+ // 处理 Index 注解
+ $indexAttrs = $ref->getAttributes(Index::class);
+ foreach ($indexAttrs as $attr) {
+ /** @var Index $idx */
+ $idx = $attr->newInstance();
+ $cols = is_array($idx->columns) ? $idx->columns : [$idx->columns];
+ $colStr = "`" . implode("`,`", $cols) . "`";
+ $keyName = $idx->name ?: $cols[0]; // 默认使用第一列名
+
+ if ($idx->type === 'UNIQUE') {
+ $lines[] = "UNIQUE KEY `$keyName` ($colStr)";
+ } elseif ($idx->type === 'FULLTEXT') {
+ $lines[] = "FULLTEXT KEY `$keyName` ($colStr)";
+ } else {
+ $lines[] = "KEY `$keyName` ($colStr)";
+ }
+ }
+
+ $body = implode(",\n ", $lines);
+ $comment = $tableAttr->comment ? " COMMENT='{$tableAttr->comment}'" : "";
+
+ return "CREATE TABLE `$tableName` (\n $body\n) ENGINE={$tableAttr->engine} DEFAULT CHARSET={$tableAttr->charset}$comment";
+ }
+
+ protected function restoreData($newTable, $oldTable, array $properties)
+ {
+ $fields = [];
+ foreach ($properties as $prop) {
+ $fieldAttrs = $prop->getAttributes(Field::class);
+ if (!empty($fieldAttrs)) {
+ $fields[] = "`" . $prop->getName() . "`";
+ }
+ }
+
+ if (empty($fields)) return;
+
+ try {
+ $oldFieldsRaw = Db::connect($this->connection)->getFields($oldTable);
+ $oldFields = array_keys($oldFieldsRaw);
+
+ $commonFields = [];
+ foreach ($properties as $prop) {
+ $name = $prop->getName();
+ if (in_array($name, $oldFields)) {
+ $commonFields[] = "`$name`";
+ }
+ }
+
+ if (empty($commonFields)) return;
+
+ $commonStr = implode(',', $commonFields);
+
+ $sql = "INSERT INTO `$newTable` ($commonStr) SELECT $commonStr FROM `$oldTable`";
+ Db::connect($this->connection)->execute($sql);
+
+ } catch (\Exception $e) {
+ throw new \Exception("Data migration failed: " . $e->getMessage());
+ }
+ }
+}