-- Runnable three-tier code generation templates
-- Import this script after sql/db.sql.
-- Template ids: 9201 backend, 9202 admin_frontend, 9203 frontend

SET FOREIGN_KEY_CHECKS = 0;

DELETE FROM sys_project_template WHERE template_id IN (9201, 9202, 9203);
DELETE FROM sys_project_structure WHERE template_id IN (9201, 9202, 9203) OR node_id BETWEEN 920100 AND 920399;
DELETE FROM sys_template_file WHERE template_id IN (9201, 9202, 9203) OR template_file_id BETWEEN 920100 AND 920399;
DELETE FROM sys_template WHERE template_id IN (9201, 9202, 9203);

INSERT INTO sys_template (template_id, template_name, template_path, template_desc, template_type, template_status, create_by, create_time, remark) VALUES
(9201, 'Runnable Backend Template', '/runnable/backend', 'Spring Boot + MyBatis Plus runnable backend', 'backend', 0, 'admin', sysdate(), 'three-tier runnable template'),
(9202, 'Runnable Admin Frontend Template', '/runnable/admin_frontend', 'Vue2 + Element UI runnable admin frontend', 'admin_frontend', 0, 'admin', sysdate(), 'three-tier runnable template'),
(9203, 'Runnable Portal Frontend Template', '/runnable/frontend', 'Vue2 + Element UI runnable portal frontend', 'frontend', 0, 'admin', sysdate(), 'three-tier runnable template');

-- Backend template files
INSERT INTO sys_template_file (template_file_id, template_id, file_name, module_id, file_path, file_content, create_by, create_time) VALUES
(920101, 9201, 'pom.xml.vm', NULL, 'pom.xml.vm', '<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.9</version>
        <relativePath/>
    </parent>

    <groupId>${packageName}</groupId>
    <artifactId>${businessName}-service</artifactId>
    <version>1.0.0</version>
    <name>${functionName}</name>
    <description>${functionName} generated by qing template</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.20</version>
        </dependency>

        <dependency>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
            <version>4.1.2</version>
        </dependency>

        <dependency>
            <groupId>io.swagger</groupId>
            <artifactId>swagger-annotations</artifactId>
            <version>1.6.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
', 'admin', sysdate()),
(920102, 9201, 'README.md.vm', NULL, 'README.md.vm', '# ${functionName}

这是由 qing 模板生成的单表 Spring Boot + Vue2 项目。

## 后端启动

1. 创建 MySQL 数据库，默认库名为 `vip`。
2. 执行 `sql/${businessName}.sql` 中的建表语句。
3. 按需修改 `src/main/resources/application.yml` 里的数据库账号密码。
4. 在项目根目录运行：

```bash
mvn spring-boot:run
```

The service starts at `http://localhost:8080`.

## 前端启动

```bash
cd vue
npm install
npm run dev
```

前端开发服务默认把 `/api` 代理到 `http://localhost:8080`。
', 'admin', sysdate()),
(920103, 9201, 'Application.java.vm', NULL, 'Application.java.vm', 'package ${packageName};

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("${packageName}.mapper")
@SpringBootApplication
public class ${ClassName}Application
{
    public static void main(String[] args)
    {
        SpringApplication.run(${ClassName}Application.class, args);
    }
}
', 'admin', sysdate()),
(920104, 9201, 'Result.java.vm', NULL, 'Result.java.vm', 'package ${packageName}.common;

import java.io.Serializable;

public class Result<T> implements Serializable
{
    private static final long serialVersionUID = 1L;

    private String code;
    private String msg;
    private T data;

    public Result()
    {
    }

    public Result(String code, String msg, T data)
    {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public static <T> Result<T> success()
    {
        return new Result<T>("200", "操作成功", null);
    }

    public static <T> Result<T> success(T data)
    {
        return new Result<T>("200", "操作成功", data);
    }

    public static <T> Result<T> error(String msg)
    {
        return new Result<T>("500", msg, null);
    }

    public String getCode()
    {
        return code;
    }

    public void setCode(String code)
    {
        this.code = code;
    }

    public String getMsg()
    {
        return msg;
    }

    public void setMsg(String msg)
    {
        this.msg = msg;
    }

    public T getData()
    {
        return data;
    }

    public void setData(T data)
    {
        this.data = data;
    }
}
', 'admin', sysdate()),
(920105, 9201, 'MybatisPlusConfig.java.vm', NULL, 'MybatisPlusConfig.java.vm', 'package ${packageName}.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig
{
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor()
    {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}
', 'admin', sysdate()),
(920106, 9201, 'CorsConfig.java.vm', NULL, 'CorsConfig.java.vm', 'package ${packageName}.config;

import ${packageName}.security.PortalAuthInterceptor;
import javax.annotation.Resource;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer
{
    @Resource
    private PortalAuthInterceptor portalAuthInterceptor;

    @Override
    public void addCorsMappings(CorsRegistry registry)
    {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry)
    {
        registry.addInterceptor(portalAuthInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/auth/login", "/auth/admin/login", "/business-blocks/**", "/files/**", "/error");
    }
}
', 'admin', sysdate()),
(920107, 9201, 'FileController.java.vm', NULL, 'FileController.java.vm', 'package ${packageName}.controller;

import ${packageName}.common.Result;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/files")
public class FileController
{
    private final Path uploadRoot = Paths.get("uploads");

    @PostMapping("/upload")
    public Result<Map<String, String>> upload(@RequestParam("file") MultipartFile file) throws IOException
    {
        Files.createDirectories(uploadRoot);
        String originalName = StringUtils.cleanPath(file.getOriginalFilename() == null ? "file" : file.getOriginalFilename());
        String suffix = "";
        int dotIndex = originalName.lastIndexOf(''.'');
        if (dotIndex >= 0)
        {
            suffix = originalName.substring(dotIndex);
        }
        String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
        Files.copy(file.getInputStream(), uploadRoot.resolve(fileName), StandardCopyOption.REPLACE_EXISTING);

        Map<String, String> data = new HashMap<String, String>();
        data.put("name", originalName);
        data.put("url", "/files/" + fileName);
        return Result.success(data);
    }

    @GetMapping("/{fileName:.+}")
    public ResponseEntity<Resource> getFile(@PathVariable String fileName) throws MalformedURLException
    {
        Path filePath = uploadRoot.resolve(fileName).normalize();
        Resource resource = new UrlResource(filePath.toUri());
        if (!resource.exists())
        {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\\"" + resource.getFilename() + "\\"")
                .body(resource);
    }
}
', 'admin', sysdate()),
(920108, 9201, 'application.yml.vm', NULL, 'application.yml.vm', '#set($d = ''$'')
server:
  port: ${d}{SERVER_PORT:8080}

spring:
  application:
    name: ${businessName}-service
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: ${d}{DB_URL:jdbc:mysql://localhost:3306/vip?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8}
    username: ${d}{DB_USERNAME:root}
    password: ${d}{DB_PASSWORD:[REDACTED]
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB

mybatis-plus:
  mapper-locations: classpath*:mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
', 'admin', sysdate()),
(920109, 9201, 'entity.java.vm', NULL, 'entity.java.vm', 'package ${packageName}.entity;

#foreach ($import in $importList)
import ${import};
#end
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import cn.hutool.core.annotation.Alias;

/**
 * ${functionName}对象 ${tableName}
 *
 * @author ${author}
 * @date ${datetime}
 */
#if($table.crud || $table.sub)
#set($Entity="BaseEntity")
#elseif($table.tree)
#set($Entity="TreeEntity")
#end
@Getter
@Setter
@Accessors(chain = true)
@ApiModel(value = "${ClassName}对象", description = "${functionName}")
@TableName("${tableName}")
public class ${ClassName}
{
    private static final long serialVersionUID = 1L;

#foreach ($column in $columns)
    /** $column.columnComment */
#if($column.list)
#set($parentheseIndex=$column.columnComment.indexOf("（"))
#if($parentheseIndex != -1)
#set($comment=$column.columnComment.substring(0, $parentheseIndex))
#else
#set($comment=$column.columnComment)
#end
#if($column.javaType == ''Date'')
    @JsonFormat(pattern = "yyyy-MM-dd")
#else
#end
#end
    @ApiModelProperty("${column.columnComment}")
    @Alias("${column.columnComment}")
#if ($column.isPk == "1")
    @TableId(value = "${column.columnName}", type = IdType.AUTO)
#else
    @TableField("${column.columnName}")
#end
    private $column.javaType $column.javaField;

#end
#if($table.sub)
    /** $table.subTable.functionName信息 */
    @ApiModelProperty("${table.subTable.functionName}信息")
    private List<${subClassName}> ${subclassName}List;

#end
    @Override
    public String toString() {
        return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
#foreach ($column in $columns)
#if($column.javaField.length() > 2 && $column.javaField.substring(1,2).matches("[A-Z]"))
#set($AttrName=$column.javaField)
#else
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
#end
            .append("${column.javaField}", get${AttrName}())
#end
#if($table.sub)
            .append("${subclassName}List", get${subClassName}List())
#end
            .toString();
    }
}
', 'admin', sysdate()),
(920110, 9201, 'mapper.java.vm', NULL, 'mapper.java.vm', 'package ${packageName}.mapper;

import ${packageName}.entity.${ClassName};
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * ${functionName}Mapper接口
 *
 * @author ${author}
 * @date ${datetime}
 */
public interface ${ClassName}Mapper extends BaseMapper<${ClassName}> {

}
', 'admin', sysdate()),
(920111, 9201, 'service.java.vm', NULL, 'service.java.vm', 'package ${packageName}.service;

import ${packageName}.entity.${ClassName};
import com.baomidou.mybatisplus.extension.service.IService;
#if($hasTableBusinessActions)
import java.util.Map;
#end

/**
 * ${functionName}Service接口
 *
 * @author ${author}
 * @date ${datetime}
 */
public interface I${ClassName}Service extends IService<${ClassName}> {

#if($hasTableBusinessActions)
#foreach($action in $tableBusinessActions)
    int ${action.code}(Map<String, Object> params);

#end
#end
}
', 'admin', sysdate()),
(920112, 9201, 'serviceImpl.java.vm', NULL, 'serviceImpl.java.vm', 'package ${packageName}.service.impl;

import ${packageName}.entity.${ClassName};
import ${packageName}.mapper.${ClassName}Mapper;
import ${packageName}.service.I${ClassName}Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
#if($hasTableBusinessActions)
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
#end
import org.springframework.stereotype.Service;

/**
 * ${functionName}Service业务层处理
 *
 * @author ${author}
 * @date ${datetime}
 */
@Service
public class ${ClassName}ServiceImpl extends ServiceImpl<${ClassName}Mapper, ${ClassName}> implements I${ClassName}Service {

#if($hasTableBusinessActions)
    // STRICT_TARGET_TABLE_CONDITIONS
    @Resource
    private JdbcTemplate jdbcTemplate;

#foreach($action in $tableBusinessActions)
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int ${action.code}(Map<String, Object> params)
    {
        if (params == null)
        {
            throw new IllegalArgumentException("业务动作参数不能为空");
        }
#if($action.requestFields)
#foreach($field in $action.requestFields)
        requireBusinessParam(params, "${field}");
#end
#end
#if($action.ruleChecks)
#foreach($rule in $action.ruleChecks)
#set($conditionFields = $rule.conditionFields)
#if($rule.type == "EXISTS")
        assertExists(params, "${rule.targetTable}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#elseif($rule.type == "NOT_EXISTS")
        assertNotExists(params, "${rule.targetTable}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#elseif($rule.type == "FIELD_EQUALS")
        assertFieldEquals(params, "${rule.targetTable}", "${rule.targetField}", "${rule.expectedValue}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#elseif($rule.type == "FIELD_NOT_EQUALS")
        assertFieldNotEquals(params, "${rule.targetTable}", "${rule.targetField}", "${rule.expectedValue}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#elseif($rule.type == "FIELD_IN")
        assertFieldIn(params, "${rule.targetTable}", "${rule.targetField}", new String[] {#foreach($value in $rule.expectedValues)"${value}"#if($foreach.hasNext), #end#end}, new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#elseif($rule.type == "NUMBER_GTE")
        assertNumberGte(params, "${rule.targetTable}", "${rule.targetField}", "${rule.compareValue}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${rule.message}");
#end
#end
#end
        int rows = 0;
#if($action.effects)
#foreach($effect in $action.effects)
#if($effect.type == "UPDATE_FIELD")
#set($conditionFields = $effect.conditionFields)
        rows += executeUpdateFieldEffect(params, "${effect.targetTable}", "${effect.targetField}", "${effect.value}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end});
#elseif($effect.type == "INSERT_ROW")
#set($effectIndex = $foreach.index)
        Map<String, Object> insertValues${effectIndex} = new java.util.LinkedHashMap<String, Object>();
#foreach($entry in $effect.values.entrySet())
        insertValues${effectIndex}.put("${entry.key}", resolveBusinessValue(params, "${entry.value}"));
#end
        rows += insertRow("${effect.targetTable}", insertValues${effectIndex});
#elseif($effect.type == "UPDATE_FIELDS")
#set($conditionFields = $effect.conditionFields)
#set($effectIndex = $foreach.index)
        Map<String, Object> updateValues${effectIndex} = new java.util.LinkedHashMap<String, Object>();
#foreach($entry in $effect.values.entrySet())
        updateValues${effectIndex}.put("${entry.key}", resolveBusinessValue(params, "${entry.value}"));
#end
        rows += updateFields(params, "${effect.targetTable}", updateValues${effectIndex}, new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end});
#elseif($effect.type == "SET_STATUS")
#set($conditionFields = $effect.conditionFields)
        rows += setStatus(params, "${effect.targetTable}", "${effect.targetField}", resolveBusinessValue(params, "${effect.value}"), new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end});
#elseif($effect.type == "INCREASE_NUMBER")
#set($conditionFields = $effect.conditionFields)
        rows += increaseNumberField(params, "${effect.targetTable}", "${effect.targetField}", "${effect.amount}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end});
#elseif($effect.type == "DECREASE_NUMBER")
#set($conditionFields = $effect.conditionFields)
        rows += decreaseNumberField(params, "${effect.targetTable}", "${effect.targetField}", "${effect.amount}", "${effect.minValue}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}, "${effect.message}");
#end
#end
#end
        return rows;
    }

#end
    private void requireBusinessParam(Map<String, Object> params, String field)
    {
        if (!params.containsKey(field) || params.get(field) == null || String.valueOf(params.get(field)).trim().isEmpty())
        {
            throw new IllegalArgumentException("缺少业务动作参数：" + field);
        }
    }

    private void assertExists(Map<String, Object> params, String tableName, String[] conditionFields, String message)
    {
        if (countRows(params, tableName, conditionFields) <= 0)
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务记录不存在"));
        }
    }

    private void assertNotExists(Map<String, Object> params, String tableName, String[] conditionFields, String message)
    {
        if (countRows(params, tableName, conditionFields) > 0)
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务记录已存在"));
        }
    }

    private void assertFieldEquals(Map<String, Object> params, String tableName, String targetField,
                                   String expectedValue, String[] conditionFields, String message)
    {
        Object value = querySingleField(params, tableName, targetField, conditionFields);
        if (value == null || !String.valueOf(resolveBusinessValue(params, expectedValue)).equals(String.valueOf(value)))
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务规则校验失败"));
        }
    }

    private void assertFieldNotEquals(Map<String, Object> params, String tableName, String targetField,
                                      String expectedValue, String[] conditionFields, String message)
    {
        Object value = querySingleField(params, tableName, targetField, conditionFields);
        if (value != null && String.valueOf(resolveBusinessValue(params, expectedValue)).equals(String.valueOf(value)))
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务规则校验失败"));
        }
    }

    private void assertFieldIn(Map<String, Object> params, String tableName, String targetField,
                               String[] expectedValues, String[] conditionFields, String message)
    {
        Object value = querySingleField(params, tableName, targetField, conditionFields);
        String current = value == null ? "" : String.valueOf(value);
        for (String expectedValue : expectedValues)
        {
            if (String.valueOf(resolveBusinessValue(params, expectedValue)).equals(current))
            {
                return;
            }
        }
        throw new IllegalArgumentException(defaultMessage(message, "业务规则校验失败"));
    }

    private void assertNumberGte(Map<String, Object> params, String tableName, String targetField,
                                 String compareValue, String[] conditionFields, String message)
    {
        Object value = querySingleField(params, tableName, targetField, conditionFields);
        BigDecimal current = toBigDecimal(value, "业务数值无效");
        BigDecimal expected = resolveBusinessNumber(params, compareValue);
        if (current.compareTo(expected) < 0)
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务数值校验失败"));
        }
    }

    private int insertRow(String tableName, Map<String, Object> values)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务动作目标表无效");
        if (values == null || values.isEmpty())
        {
            throw new IllegalArgumentException("业务新增字段不能为空");
        }
        StringBuilder fields = new StringBuilder();
        StringBuilder placeholders = new StringBuilder();
        List<Object> args = new ArrayList<Object>();
        for (Map.Entry<String, Object> entry : values.entrySet())
        {
            if (fields.length() > 0)
            {
                fields.append(", ");
                placeholders.append(", ");
            }
            fields.append(requireSafeIdentifier(entry.getKey(), "业务新增字段无效"));
            placeholders.append("?");
            args.add(entry.getValue());
        }
        return jdbcTemplate.update("insert into " + safeTableName + " (" + fields + ") values (" + placeholders + ")",
                args.toArray());
    }

    private int updateFields(Map<String, Object> params, String tableName, Map<String, Object> values,
                             String[] conditionFields)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务动作目标表无效");
        if (values == null || values.isEmpty())
        {
            throw new IllegalArgumentException("业务更新字段不能为空");
        }
        StringBuilder sets = new StringBuilder();
        List<Object> args = new ArrayList<Object>();
        for (Map.Entry<String, Object> entry : values.entrySet())
        {
            if (sets.length() > 0)
            {
                sets.append(", ");
            }
            sets.append(requireSafeIdentifier(entry.getKey(), "业务更新字段无效")).append(" = ?");
            args.add(entry.getValue());
        }
        String whereClause = buildWhereClause(params, conditionFields);
        appendConditionValues(args, params, conditionFields);
        return jdbcTemplate.update("update " + safeTableName + " set " + sets + " where " + whereClause,
                args.toArray());
    }

    private int setStatus(Map<String, Object> params, String tableName, String targetField, Object value,
                          String[] conditionFields)
    {
        Map<String, Object> values = new java.util.LinkedHashMap<String, Object>();
        values.put(targetField, value);
        return updateFields(params, tableName, values, conditionFields);
    }

    private int increaseNumberField(Map<String, Object> params, String tableName, String targetField,
                                    String amountExpression, String[] conditionFields)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务动作目标表无效");
        String safeTargetField = requireSafeIdentifier(targetField, "业务动作目标字段无效");
        BigDecimal amount = resolveBusinessNumber(params, amountExpression);
        String whereClause = buildWhereClause(params, conditionFields);
        List<Object> args = new ArrayList<Object>();
        args.add(amount);
        appendConditionValues(args, params, conditionFields);
        return jdbcTemplate.update("update " + safeTableName + " set " + safeTargetField + " = "
                + safeTargetField + " + ? where " + whereClause, args.toArray());
    }

    private int decreaseNumberField(Map<String, Object> params, String tableName, String targetField,
                                    String amountExpression, String minValueExpression,
                                    String[] conditionFields, String message)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务动作目标表无效");
        String safeTargetField = requireSafeIdentifier(targetField, "业务动作目标字段无效");
        BigDecimal amount = resolveBusinessNumber(params, amountExpression);
        BigDecimal minValue = resolveBusinessNumber(params,
                minValueExpression == null || minValueExpression.length() == 0 ? "0" : minValueExpression);
        String whereClause = buildWhereClause(params, conditionFields);
        List<Object> args = new ArrayList<Object>();
        args.add(amount);
        appendConditionValues(args, params, conditionFields);
        args.add(amount);
        args.add(minValue);
        int rows = jdbcTemplate.update("update " + safeTableName + " set " + safeTargetField + " = "
                + safeTargetField + " - ? where " + whereClause + " and " + safeTargetField + " - ? >= ?",
                args.toArray());
        if (rows == 0)
        {
            throw new IllegalArgumentException(defaultMessage(message, "业务数值扣减失败"));
        }
        return rows;
    }

    private int executeUpdateFieldEffect(Map<String, Object> params, String tableName, String targetField,
                                         String valueExpression, String[] conditionFields)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务动作目标表无效");
        String safeTargetField = requireSafeIdentifier(targetField, "业务动作目标字段无效");
        String safeValueExpression = requireSafeExpression(valueExpression);
        String whereClause = buildWhereClause(params, conditionFields);
        return jdbcTemplate.update("update " + safeTableName + " set " + safeTargetField + " = "
                + safeValueExpression + " where " + whereClause, conditionValues(params, conditionFields));
    }

    private String buildWhereClause(Map<String, Object> params, String[] conditionFields)
    {
        if (conditionFields == null || conditionFields.length == 0)
        {
            throw new IllegalArgumentException("业务动作条件字段不能为空");
        }
        StringBuilder where = new StringBuilder();
        for (String field : conditionFields)
        {
            String safeField = requireSafeIdentifier(field, "业务动作条件字段无效");
            requireBusinessParam(params, safeField);
            if (where.length() > 0)
            {
                where.append(" and ");
            }
            where.append(safeField).append(" = ?");
        }
        return where.toString();
    }

    private Object[] conditionValues(Map<String, Object> params, String[] conditionFields)
    {
        Object[] values = new Object[conditionFields.length];
        for (int i = 0; i < conditionFields.length; i++)
        {
            values[i] = params.get(conditionFields[i]);
        }
        return values;
    }

    private int countRows(Map<String, Object> params, String tableName, String[] conditionFields)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务规则目标表无效");
        String whereClause = buildWhereClause(params, conditionFields);
        Integer count = jdbcTemplate.queryForObject("select count(1) from " + safeTableName + " where "
                + whereClause, conditionValues(params, conditionFields), Integer.class);
        return count == null ? 0 : count.intValue();
    }

    private Object querySingleField(Map<String, Object> params, String tableName, String targetField,
                                    String[] conditionFields)
    {
        String safeTableName = requireSafeIdentifier(tableName, "业务规则目标表无效");
        String safeTargetField = requireSafeIdentifier(targetField, "业务规则目标字段无效");
        String whereClause = buildWhereClause(params, conditionFields);
        List<Map<String, Object>> rows = jdbcTemplate.queryForList("select " + safeTargetField + " from "
                + safeTableName + " where " + whereClause + " limit 1", conditionValues(params, conditionFields));
        return rows.isEmpty() ? null : rows.get(0).get(safeTargetField);
    }

    private void appendConditionValues(List<Object> args, Map<String, Object> params, String[] conditionFields)
    {
        for (Object value : conditionValues(params, conditionFields))
        {
            args.add(value);
        }
    }

    private Object resolveBusinessValue(Map<String, Object> params, String valueExpression)
    {
        if (valueExpression == null)
        {
            return null;
        }
        if ("#[[${now}]]#".equals(valueExpression))
        {
            return new Date();
        }
        if ("#[[${current_user.id}]]#".equals(valueExpression)
                || "#[[${current_user_id}]]#".equals(valueExpression))
        {
            requireBusinessParam(params, "__currentUserId");
            return params.get("__currentUserId");
        }
        if (valueExpression.startsWith("#[[${param.]]#") && valueExpression.endsWith("}"))
        {
            String field = valueExpression.substring(8, valueExpression.length() - 1);
            requireBusinessParam(params, field);
            return params.get(field);
        }
        return valueExpression;
    }

    private BigDecimal resolveBusinessNumber(Map<String, Object> params, String valueExpression)
    {
        return toBigDecimal(resolveBusinessValue(params, valueExpression), "业务数值表达式无效");
    }

    private BigDecimal toBigDecimal(Object value, String message)
    {
        try
        {
            return new BigDecimal(String.valueOf(value));
        }
        catch (RuntimeException e)
        {
            throw new IllegalArgumentException(message);
        }
    }

    private String defaultMessage(String message, String fallback)
    {
        return message == null || message.length() == 0 ? fallback : message;
    }

    private String requireSafeIdentifier(String value, String message)
    {
        if (value == null || !value.matches("[A-Za-z][A-Za-z0-9_]*"))
        {
            throw new IllegalArgumentException(message);
        }
        return value;
    }

    private String requireSafeExpression(String valueExpression)
    {
        if (valueExpression == null || !valueExpression.matches("[A-Za-z0-9_ +*/().-]{1,200}")
                || valueExpression.contains(";") || valueExpression.contains("--"))
        {
            throw new IllegalArgumentException("业务动作表达式无效");
        }
        return valueExpression;
    }
#end
}
', 'admin', sysdate()),
(920113, 9201, 'controller.java.vm', NULL, 'controller.java.vm', 'package ${packageName}.controller;

import cn.hutool.poi.excel.ExcelReader;
import cn.hutool.poi.excel.ExcelUtil;
import cn.hutool.poi.excel.ExcelWriter;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import ${packageName}.common.Result;
import ${packageName}.entity.${ClassName};
#if($hasQingCurrentUserColumn || $hasTableBusinessActions)
import ${packageName}.entity.PortalUser;
import ${packageName}.security.PortalAuthTokenStore;
#end
import ${packageName}.service.I${ClassName}Service;
import java.io.InputStream;
import java.net.URLEncoder;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.ServletOutputStream;
#if($hasQingCurrentUserColumn || $hasTableBusinessActions)
import javax.servlet.http.HttpServletRequest;
#end
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * ${functionName}Controller
 *
 * @author ${author}
 * @date ${datetime}
 */
@RestController
public class ${ClassName}Controller
{
    @Resource
    private I${ClassName}Service ${className}Service;

#if($hasQingCurrentUserColumn || $hasTableBusinessActions)
    @Resource
    private PortalAuthTokenStore tokenStore;

#end

    #if($allowAdd == ''1'' || $allowEdit == ''1'')
    @PostMapping("/${moduleName}/${businessName}")
    public Result<Void> save(@RequestBody ${ClassName} ${className})
    {
        #if($allowAdd == ''1'' && $allowEdit == ''1'')
        ${className}Service.saveOrUpdate(${className});
        #elseif($allowAdd == ''1'')
        ${className}Service.save(${className});
        #else
        ${className}Service.updateById(${className});
        #end
        return Result.success();
    }
    #end

    #if($allowRemove == ''1'')
    @DeleteMapping("/${moduleName}/${businessName}/{${pkColumn.javaField}}")
    public Result<Void> delete(@PathVariable ${pkColumn.javaType} ${pkColumn.javaField})
    {
        ${className}Service.removeById(${pkColumn.javaField});
        return Result.success();
    }

    @PostMapping("/${moduleName}/${businessName}/del/batch")
    public Result<Void> deleteBatch(@RequestBody List<${pkColumn.javaType}> ${pkColumn.javaField}s)
    {
        ${className}Service.removeByIds(${pkColumn.javaField}s);
        return Result.success();
    }
    #end

    @GetMapping("/${moduleName}/${businessName}")
    public Result<List<${ClassName}>> findAll()
    {
        return Result.success(${className}Service.list());
    }

    @GetMapping("/${moduleName}/${businessName}/{${pkColumn.javaField}}")
    public Result<${ClassName}> findOne(@PathVariable ${pkColumn.javaType} ${pkColumn.javaField})
    {
        return Result.success(${className}Service.getById(${pkColumn.javaField}));
    }

    @GetMapping("/${moduleName}/${businessName}/page")
    public Result<Page<${ClassName}>> findPage(@RequestParam(required = false, defaultValue = "1") Integer pageNum,
                                               @RequestParam(required = false, defaultValue = "10") Integer pageSize,
                                               @RequestParam(required = false) Map<String, Object> params#if($hasQingCurrentUserColumn),
                                               HttpServletRequest request#end)
    {
        QueryWrapper<${ClassName}> queryWrapper = new QueryWrapper<${ClassName}>();
#if($hasQingCurrentUserColumn)
        if (isCurrentUserOnly(params))
        {
            PortalUser currentUser = tokenStore.getPortalUser(resolveToken(request));
            if (currentUser == null)
            {
                return Result.success(new Page<${ClassName}>(pageNum, pageSize));
            }
            queryWrapper.eq("${qingCurrentUserColumn.columnName}", currentUser.getUserId());
        }
#end
#foreach ($column in $columns)
#if($column.isQuery == "1")
#set($queryType=$column.queryType)
#set($javaField=$column.javaField)
#set($columnName=$column.columnName)
#if($queryType == "EQ")
        queryWrapper.eq(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "NE")
        queryWrapper.ne(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "GT")
        queryWrapper.gt(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "GTE")
        queryWrapper.ge(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "LT")
        queryWrapper.lt(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "LTE")
        queryWrapper.le(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#elseif($queryType == "LIKE")
        queryWrapper.like(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#else
        queryWrapper.eq(hasValue(params.get("${javaField}")), "${columnName}", params.get("${javaField}"));
#end
#end
#end
        queryWrapper.orderByDesc("${pkColumn.columnName}");
        return Result.success(${className}Service.page(new Page<${ClassName}>(pageNum, pageSize), queryWrapper));
    }

    @GetMapping("/${moduleName}/${businessName}/export")
    public void export(HttpServletResponse response) throws Exception
    {
        List<${ClassName}> list = ${className}Service.list();
        ExcelWriter writer = ExcelUtil.getWriter(true);
        writer.write(list, true);

        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
        String fileName = URLEncoder.encode("${functionName}信息表", "UTF-8");
        response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");

        ServletOutputStream out = response.getOutputStream();
        writer.flush(out, true);
        out.close();
        writer.close();
    }

    #if($allowAdd == ''1'')
    @PostMapping("/${moduleName}/${businessName}/import")
    public Result<Void> imp(MultipartFile file) throws Exception
    {
        InputStream inputStream = file.getInputStream();
        ExcelReader reader = ExcelUtil.getReader(inputStream);
        List<${ClassName}> list = reader.readAll(${ClassName}.class);
        ${className}Service.saveBatch(list);
        return Result.success();
    }
    #end

#if($hasTableBusinessActions)
#foreach($action in $tableBusinessActions)
#if($action.method == "GET")
    @GetMapping("${action.path}")
    public Result<Integer> ${action.code}(@RequestParam(required = false) Map<String, Object> params,
                                          HttpServletRequest request)
#elseif($action.method == "PUT")
    @org.springframework.web.bind.annotation.PutMapping("${action.path}")
    public Result<Integer> ${action.code}(@RequestBody(required = false) Map<String, Object> params,
                                          HttpServletRequest request)
#elseif($action.method == "DELETE")
    @DeleteMapping("${action.path}")
    public Result<Integer> ${action.code}(@RequestBody(required = false) Map<String, Object> params,
                                          HttpServletRequest request)
#else
    @PostMapping("${action.path}")
    public Result<Integer> ${action.code}(@RequestBody(required = false) Map<String, Object> params,
                                          HttpServletRequest request)
#end
    {
        try
        {
            Map<String, Object> actionParams = prepareBusinessActionParams(params, request, new String[] {#foreach($field in $action.currentUserFields)"${field}"#if($foreach.hasNext), #end#end});
            return Result.success(${className}Service.${action.code}(actionParams));
        }
        catch (RuntimeException e)
        {
            return Result.error(e.getMessage());
        }
    }

#end
    private Map<String, Object> prepareBusinessActionParams(Map<String, Object> params, HttpServletRequest request,
                                                             String[] currentUserFields)
    {
        Map<String, Object> actionParams = params == null
                ? new LinkedHashMap<String, Object>()
                : new LinkedHashMap<String, Object>(params);
        PortalUser currentUser = tokenStore.getPortalUser(resolveToken(request));
        if (currentUser == null)
        {
            throw new IllegalArgumentException("请先登录");
        }
        actionParams.put("__currentUserId", currentUser.getUserId());
        if (currentUserFields != null)
        {
            for (String field : currentUserFields)
            {
                if (StringUtils.isNotBlank(field))
                {
                    if ("username".equals(field) || "user_name".equals(field))
                    {
                        actionParams.put(field, currentUser.getUsername());
                    }
                    else
                    {
                        actionParams.put(field, currentUser.getUserId());
                    }
                }
            }
        }
        return actionParams;
    }

#end
    private boolean hasValue(Object value)
    {
        return value != null && StringUtils.isNotBlank(String.valueOf(value));
    }

#if($hasQingCurrentUserColumn)
    private boolean isCurrentUserOnly(Map<String, Object> params)
    {
        return params != null && "true".equalsIgnoreCase(String.valueOf(params.get("__currentUserOnly")));
    }

#end
#if($hasQingCurrentUserColumn || $hasTableBusinessActions)
    private String resolveToken(HttpServletRequest request)
    {
        String authorization = request.getHeader("Authorization");
        if (authorization != null && authorization.startsWith("Bearer "))
        {
            return authorization.substring("Bearer ".length());
        }
        return request.getHeader("X-Portal-Token");
    }
#end
}
', 'admin', sysdate()),
(920114, 9201, 'mapper.xml.vm', NULL, 'mapper.xml.vm', '<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="${packageName}.mapper.${ClassName}Mapper">

    <!-- 通用查询映射结果 -->
    <resultMap type="${packageName}.entity.${ClassName}" id="BaseResultMap">
#foreach ($column in $columns)
#if ($column.isPk == "1")
        <id column="${column.columnName}" property="${column.javaField}"/>
#else
        <result column="${column.columnName}" property="${column.javaField}"/>
#end
#end
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
#foreach ($column in $columns)
        ${column.columnName}#if($foreach.count != $columns.size()),#end

#end
    </sql>

</mapper>
', 'admin', sysdate()),
(920115, 9201, 'sql.vm', NULL, 'sql.vm', '-- ${functionName} table
-- Generated by qing template at ${datetime}

${table.createTableSql}

create table if not exists t_admin (
  admin_id bigint not null auto_increment comment ''Admin ID'',
  username varchar(64) not null comment ''Username'',
  password_hash varchar(64) not null comment ''SHA-256 password hash'',
  nickname varchar(64) default null comment ''Nickname'',
  status char(1) default ''0'' comment ''Status, 0 normal 1 disabled'',
  create_time datetime default current_timestamp comment ''Create time'',
  primary key (admin_id),
  unique key uk_t_admin_username (username)
) engine=innodb default charset=utf8mb4 comment=''Admin user table'';

insert into t_admin (username, password_hash, nickname, status)
select ''admin'',
       ''8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'',
       ''Administrator'',
       ''0''
where not exists (select 1 from t_admin where username = ''admin'');

create table if not exists t_user (
  user_id bigint not null auto_increment comment ''User ID'',
  username varchar(64) not null comment ''Username'',
  password_hash varchar(64) not null comment ''SHA-256 password hash'',
  nickname varchar(64) default null comment ''Nickname'',
  status char(1) default ''0'' comment ''Status, 0 normal 1 disabled'',
  create_time datetime default current_timestamp comment ''Create time'',
  primary key (user_id),
  unique key uk_t_user_username (username)
) engine=innodb default charset=utf8mb4 comment=''Portal user table'';

insert into t_user (username, password_hash, nickname, status)
select ''user'',
       ''8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92'',
       ''User'',
       ''0''
where not exists (select 1 from t_user where username = ''user'');
', 'admin', sysdate()),
(920116, 9201, 'PortalUser.java.vm', NULL, 'PortalUser.java.vm', 'package ${packageName}.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

@Getter
@Setter
@Accessors(chain = true)
@TableName("t_user")
public class PortalUser
{
    @TableId(value = "user_id", type = IdType.AUTO)
    private Long userId;

    private String username;

    private String passwordHash;

    private String nickname;

    private String status;
}
', 'admin', sysdate()),
(920117, 9201, 'PortalUserMapper.java.vm', NULL, 'PortalUserMapper.java.vm', 'package ${packageName}.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import ${packageName}.entity.PortalUser;

public interface PortalUserMapper extends BaseMapper<PortalUser>
{
}
', 'admin', sysdate()),
(920118, 9201, 'PortalAuthTokenStore.java.vm', NULL, 'PortalAuthTokenStore.java.vm', 'package ${packageName}.security;

import ${packageName}.entity.AdminUser;
import ${packageName}.entity.PortalUser;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;

@Component
public class PortalAuthTokenStore
{
    private final Map<String, Object> sessions = new ConcurrentHashMap<String, Object>();

    public String issue(Object user)
    {
        String token = UUID.randomUUID().toString().replace("-", "");
        sessions.put(token, user);
        return token;
    }

    public Object getUser(String token)
    {
        if (token == null || token.trim().isEmpty())
        {
            return null;
        }
        return sessions.get(token);
    }

    public PortalUser getPortalUser(String token)
    {
        Object user = getUser(token);
        return user instanceof PortalUser ? (PortalUser) user : null;
    }

    public AdminUser getAdminUser(String token)
    {
        Object user = getUser(token);
        return user instanceof AdminUser ? (AdminUser) user : null;
    }

    public boolean validate(String token)
    {
        return getUser(token) != null;
    }

    public void revoke(String token)
    {
        if (token != null)
        {
            sessions.remove(token);
        }
    }
}
', 'admin', sysdate()),
(920119, 9201, 'PortalAuthInterceptor.java.vm', NULL, 'PortalAuthInterceptor.java.vm', 'package ${packageName}.security;

import java.io.IOException;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class PortalAuthInterceptor implements HandlerInterceptor
{
    @Resource
    private PortalAuthTokenStore tokenStore;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws IOException
    {
        if ("OPTIONS".equalsIgnoreCase(request.getMethod()))
        {
            return true;
        }

        String token = resolveToken(request);
        if (tokenStore.validate(token))
        {
            return true;
        }

        response.setStatus(401);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\\"code\\":\\"401\\",\\"msg\\":\\"请先登录\\",\\"data\\":null}");
        return false;
    }

    private String resolveToken(HttpServletRequest request)
    {
        String authorization = request.getHeader("Authorization");
        if (authorization != null && authorization.startsWith("Bearer "))
        {
            return authorization.substring("Bearer ".length());
        }
        return request.getHeader("X-Portal-Token");
    }
}
', 'admin', sysdate()),
(920120, 9201, 'PortalAuthController.java.vm', NULL, 'PortalAuthController.java.vm', 'package ${packageName}.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import ${packageName}.common.Result;
import ${packageName}.entity.PortalUser;
import ${packageName}.mapper.PortalUserMapper;
import ${packageName}.security.PortalAuthTokenStore;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class PortalAuthController
{
    @Resource
    private PortalUserMapper portalUserMapper;

    @Resource
    private PortalAuthTokenStore tokenStore;

    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody LoginBody body)
    {
        if (body == null || isBlank(body.getUsername()) || isBlank(body.getPassword()))
        {
            return Result.error("请输入用户名和密码");
        }

        PortalUser user = portalUserMapper.selectOne(new QueryWrapper<PortalUser>()
                .eq("username", body.getUsername())
                .eq("status", "0")
                .last("limit 1"));

        if (user == null || !passwordHash(body.getPassword()).equalsIgnoreCase(user.getPasswordHash()))
        {
            return Result.error("账号或密码错误");
        }

        String token = tokenStore.issue(user);
        user.setPasswordHash(null);

        Map<String, Object> data = new HashMap<String, Object>();
        data.put("token", token);
        data.put("user", user);
        return Result.success(data);
    }

    @GetMapping("/me")
    public Result<PortalUser> me(HttpServletRequest request)
    {
        PortalUser user = tokenStore.getPortalUser(resolveToken(request));
        if (user == null)
        {
            return Result.error("请先登录");
        }
        user.setPasswordHash(null);
        return Result.success(user);
    }

    @PostMapping("/logout")
    public Result<Void> logout(HttpServletRequest request)
    {
        tokenStore.revoke(resolveToken(request));
        return Result.success();
    }

    private String resolveToken(HttpServletRequest request)
    {
        String authorization = request.getHeader("Authorization");
        if (authorization != null && authorization.startsWith("Bearer "))
        {
            return authorization.substring("Bearer ".length());
        }
        return request.getHeader("X-Portal-Token");
    }

    private String passwordHash(String password)
    {
        try
        {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
            StringBuilder builder = new StringBuilder();
            for (byte item : hash)
            {
                builder.append(String.format("%02x", item));
            }
            return builder.toString();
        }
        catch (Exception e)
        {
            throw new IllegalStateException("Password hash failed", e);
        }
    }

    private boolean isBlank(String value)
    {
        return value == null || value.trim().isEmpty();
    }

    public static class LoginBody
    {
        private String username;
        private String password;

        public String getUsername()
        {
            return username;
        }

        public void setUsername(String username)
        {
            this.username = username;
        }

        public String getPassword()
        {
            return password;
        }

        public void setPassword(String password)
        {
            this.password = [REDACTED]
        }
    }
}
', 'admin', sysdate()),
(920121, 9201, 'AdminUser.java.vm', NULL, 'AdminUser.java.vm', 'package ${packageName}.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

@Getter
@Setter
@Accessors(chain = true)
@TableName("t_admin")
public class AdminUser
{
    @TableId(value = "admin_id", type = IdType.AUTO)
    private Long adminId;

    private String username;

    private String passwordHash;

    private String nickname;

    private String status;
}
', 'admin', sysdate()),
(920122, 9201, 'AdminUserMapper.java.vm', NULL, 'AdminUserMapper.java.vm', 'package ${packageName}.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import ${packageName}.entity.AdminUser;

public interface AdminUserMapper extends BaseMapper<AdminUser>
{
}
', 'admin', sysdate()),
(920123, 9201, 'AdminAuthController.java.vm', NULL, 'AdminAuthController.java.vm', 'package ${packageName}.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import ${packageName}.common.Result;
import ${packageName}.entity.AdminUser;
import ${packageName}.mapper.AdminUserMapper;
import ${packageName}.security.PortalAuthTokenStore;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth/admin")
public class AdminAuthController
{
    @Resource
    private AdminUserMapper adminUserMapper;

    @Resource
    private PortalAuthTokenStore tokenStore;

    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody LoginBody body)
    {
        if (body == null || isBlank(body.getUsername()) || isBlank(body.getPassword()))
        {
            return Result.error("请输入用户名和密码");
        }

        AdminUser user = adminUserMapper.selectOne(new QueryWrapper<AdminUser>()
                .eq("username", body.getUsername())
                .eq("status", "0")
                .last("limit 1"));

        if (user == null || !passwordHash(body.getPassword()).equalsIgnoreCase(user.getPasswordHash()))
        {
            return Result.error("账号或密码错误");
        }

        String token = tokenStore.issue(user);
        user.setPasswordHash(null);

        Map<String, Object> data = new HashMap<String, Object>();
        data.put("token", token);
        data.put("user", user);
        return Result.success(data);
    }

    @GetMapping("/me")
    public Result<AdminUser> me(HttpServletRequest request)
    {
        AdminUser user = tokenStore.getAdminUser(resolveToken(request));
        if (user == null)
        {
            return Result.error("请先登录");
        }
        user.setPasswordHash(null);
        return Result.success(user);
    }

    @PostMapping("/logout")
    public Result<Void> logout(HttpServletRequest request)
    {
        tokenStore.revoke(resolveToken(request));
        return Result.success();
    }

    private String resolveToken(HttpServletRequest request)
    {
        String authorization = request.getHeader("Authorization");
        if (authorization != null && authorization.startsWith("Bearer "))
        {
            return authorization.substring("Bearer ".length());
        }
        return request.getHeader("X-Admin-Token");
    }

    private String passwordHash(String password)
    {
        try
        {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
            StringBuilder builder = new StringBuilder();
            for (byte item : hash)
            {
                builder.append(String.format("%02x", item));
            }
            return builder.toString();
        }
        catch (Exception e)
        {
            throw new IllegalStateException("Password hash failed", e);
        }
    }

    private boolean isBlank(String value)
    {
        return value == null || value.trim().isEmpty();
    }

    public static class LoginBody
    {
        private String username;
        private String password;

        public String getUsername()
        {
            return username;
        }

        public void setUsername(String username)
        {
            this.username = username;
        }

        public String getPassword()
        {
            return password;
        }

        public void setPassword(String password)
        {
            this.password = [REDACTED]
        }
    }
}
', 'admin', sysdate());

-- Admin frontend template files
INSERT INTO sys_template_file (template_file_id, template_id, file_name, module_id, file_path, file_content, create_by, create_time) VALUES
(920201, 9202, 'package.json.vm', NULL, 'package.json.vm', '{
  "name": "${projectFileName}-admin",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vue-cli-service serve --host 0.0.0.0",
    "serve": "vue-cli-service serve --host 0.0.0.0",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "axios": "^0.27.2",
    "core-js": "^3.8.3",
    "echarts": "5.4.0",
    "element-ui": "^2.15.14",
    "vue": "^2.6.14",
    "vue-router": "^3.6.5"
  },
  "devDependencies": {
    "@vue/cli-service": "^4.5.19",
    "vue-template-compiler": "^2.6.14"
  }
}', 'admin', sysdate()),
(920202, 9202, 'babel.config.js.vm', NULL, 'babel.config.js.vm', 'module.exports = {
  presets: [
    "@vue/cli-plugin-babel/preset"
  ]
}
', 'admin', sysdate()),
(920203, 9202, 'vue.config.js.vm', NULL, 'vue.config.js.vm', 'const port = process.env.PORT || 8081
const apiBaseUrl = process.env.VUE_APP_API_BASE_URL || "http://localhost:8080"

module.exports = {
  transpileDependencies: [],
  devServer: {
    port,
    proxy: {
      "/api": {
        target: apiBaseUrl,
        changeOrigin: true,
        pathRewrite: {
          "^/api": ""
        }
      }
    }
  }
}
', 'admin', sysdate()),
(920204, 9202, 'index.html.vm', NULL, 'index.html.vm', '<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title>${projectName} Admin</title>
</head>
<body>
  <noscript>请启用 JavaScript 后访问。</noscript>
  <div id="app"></div>
</body>
</html>', 'admin', sysdate()),
(920205, 9202, 'admin-main.js.vm', NULL, 'admin-main.js.vm', 'import Vue from "vue"
import ElementUI from "element-ui"
import "element-ui/lib/theme-chalk/index.css"
import App from "./App.vue"
import router from "./router"
import request from "./utils/request"
import dict from "./utils/dict"

Vue.config.productionTip = false
Vue.use(ElementUI)
Vue.prototype.request = request
Vue.prototype._dict = dict

new Vue({
  router,
  render: h => h(App)
}).#[[$mount]]#("#app")', 'admin', sysdate()),
(920206, 9202, 'admin-App.vue.vm', NULL, 'admin-App.vue.vm', '<template>
  <div id="app">
    <router-view v-if="isLoginPage" />
    <div v-else class="admin-shell">
      <aside class="admin-sidebar">
        <router-link class="brand" to="/">
          <span class="brand-mark">ADM</span>
          <span class="brand-text">${projectName}后台</span>
        </router-link>

        <nav class="sidebar-menu">
#if($hasAdminNavGroups)
#foreach($group in $adminNavGroups)
#if($group.hasChildren)
          <div class="menu-group">
#if($group.clickable)
            <router-link class="menu-title menu-title-link" :class=''{ active: isSidebarGroupActive(${group.activePathsLiteral}) }'' to="${group.path}" exact>
              <span><i class="el-icon-menu"></i>${group.name}</span>
              <i class="el-icon-arrow-up"></i>
            </router-link>
#else
            <div class="menu-title" :class=''{ active: isSidebarGroupActive(${group.activePathsLiteral}) }''>
              <span><i class="el-icon-folder-opened"></i>${group.name}</span>
              <i class="el-icon-arrow-up"></i>
            </div>
#end
#foreach($child in $group.children)
            <router-link class="sidebar-link is-child" to="${child.path}" exact>
              <span>${child.name}</span>
            </router-link>
#end
          </div>
#else
          <router-link class="sidebar-link" to="${group.path}" exact>
            <i class="el-icon-menu"></i>
            <span>${group.name}</span>
          </router-link>
#end
#end
#else
          <router-link class="sidebar-link" to="/empty" exact>
            <i class="el-icon-warning-outline"></i>
            <span>暂无菜单</span>
          </router-link>
#end
        </nav>
      </aside>

      <section class="admin-main">
        <header class="topbar">
          <div class="breadcrumb">
            <router-link to="/">首页</router-link>
            <span>/</span>
            <strong>{{ currentTitle }}</strong>
          </div>
          <el-dropdown trigger="click" @command="handleUserCommand">
            <span class="user-entry">
              <span class="avatar">管</span>
              <span>{{ userName }}</span>
              <i class="el-icon-arrow-down"></i>
            </span>
            <el-dropdown-menu slot="dropdown">
              <el-dropdown-item command="logout">退出登录</el-dropdown-item>
            </el-dropdown-menu>
          </el-dropdown>
        </header>
        <main class="content-area">
          <router-view />
        </main>
      </section>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  computed: {
    isLoginPage() {
      return this.#[[$route]]#.path === "/login"
    },
    currentTitle() {
      return this.#[[$route]]#.meta && this.#[[$route]]#.meta.title ? this.#[[$route]]#.meta.title : "系统首页"
    },
    userName() {
      const storedUser = window.localStorage.getItem("admin-user")
      if (storedUser) {
        try {
          const user = JSON.parse(storedUser)
          return user.nickname || user.username || "管理员"
        } catch (e) {
          return "管理员"
        }
      }
      return window.localStorage.getItem("adminUserName") || "管理员"
    }
  },
  methods: {
    isSidebarGroupActive(paths) {
      return Array.isArray(paths) && paths.indexOf(this.#[[$route]]#.path) !== -1
    },
    handleUserCommand(command) {
      if (command === "logout") {
        this.request.post("/auth/admin/logout").finally(() => {
          this.clearAdminSession()
          this.#[[$router]]#.replace("/login")
        })
      }
    },
    clearAdminSession() {
      window.localStorage.removeItem("admin-token")
      window.localStorage.removeItem("admin-user")
    }
  }
}
</script>

<style>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: #f3f5fa;
  color: #303133;
  font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}

a {
  text-decoration: none;
}

.admin-shell {
  min-height: 100vh;
  display: flex;
  background: #f3f5fa;
}

.admin-sidebar {
  width: 208px;
  min-height: 100vh;
  flex: 0 0 208px;
  overflow-y: auto;
  background: #0f172a;
  color: #d8e6f0;
}

.brand {
  height: 62px;
  padding: 0 16px;
  display: flex;
  align-items: center;
  gap: 10px;
  color: #fff;
  background: #111827;
}

.brand-mark {
  display: inline-grid;
  width: 36px;
  height: 36px;
  place-items: center;
  border-radius: 8px;
  color: #ffffff;
  background: #17633b;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0;
}

.brand-text {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 17px;
  font-weight: 700;
}

.sidebar-menu {
  padding: 10px 0 24px;
}

.sidebar-link,
.menu-title {
  min-height: 44px;
  padding: 0 18px;
  display: flex;
  align-items: center;
  gap: 10px;
  color: #d8e6f0;
  font-size: 14px;
}

.menu-title {
  justify-content: space-between;
  background: #111f35;
  border-left: 3px solid transparent;
}

.menu-title-link {
  color: #d8e6f0;
}

.menu-title span {
  display: flex;
  align-items: center;
  gap: 10px;
}

.menu-title.active,
.menu-title:hover,
.sidebar-link:hover {
  color: #ffffff;
  background: #1e2f4d;
}

.sidebar-link.is-child {
  padding-left: 48px;
  background: #0b1220;
}

.sidebar-link.router-link-exact-active,
.sidebar-link.router-link-active {
  background: #2563eb;
  color: #fff;
}

.admin-main {
  min-width: 0;
  flex: 1;
}

.topbar {
  height: 56px;
  padding: 0 18px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #ffffff;
  border-bottom: 1px solid #e5e7eb;
  color: #111827;
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 14px;
}

.breadcrumb a,
.breadcrumb strong {
  color: #111827;
}

.user-entry {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: #334155;
  cursor: pointer;
}

.avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: inline-grid;
  place-items: center;
  color: #1d4ed8;
  background: #eff6ff;
  font-weight: 700;
}

.content-area {
  min-height: calc(100vh - 56px);
  padding: 8px 10px 28px;
}
</style>', 'admin', sysdate()),
(920207, 9202, 'admin-router.js.vm', NULL, 'admin-router.js.vm', 'import Vue from "vue"
import Router from "vue-router"
import Login from "@/views/login/index.vue"
#if($hasAdminRouteItems)
#foreach($component in $adminRouteComponents)
import ${component.componentName} from "${component.componentPath}"
#end
#end

Vue.use(Router)

#if($hasAdminRouteItems)
const firstPath = "${defaultAdminPath}"
#else
const firstPath = "/empty"
const Empty = { template: "<div class=\"empty-page\">暂无可管理数据</div>" }
#end

const routes = [
  {
    path: "/login",
    name: "Login",
    component: Login,
    meta: { title: "登录" }
  },
  {
    path: "/",
    redirect: firstPath
  }#if($hasAdminRouteItems)#else,
  {
    path: "/empty",
    name: "Empty",
    component: Empty,
    meta: { title: "系统首页" }
  }#end
#foreach($item in $adminRouteItems),
  {
    path: "${item.path}",
    name: "${item.code}",
    component: ${item.componentName},
    meta: { title: "${item.title}" }
  }
#end
]

const router = new Router({
  mode: "hash",
  routes
})

router.beforeEach((to, from, next) => {
  const hasToken = Boolean(window.localStorage.getItem("admin-token"))
  if (to.path !== "/login" && !hasToken) {
    next({ path: "/login", query: { redirect: to.fullPath } })
    return
  }
  if (to.path === "/login" && hasToken) {
    next(to.query.redirect || firstPath)
    return
  }
  next()
})

export default router', 'admin', sysdate()),
(920211, 9202, 'admin-login.vue.vm', NULL, 'admin-login.vue.vm', '<template>
  <div class="login-page">
    <section class="login-visual">
      <div class="login-brand">
        <span class="brand-mark">
          <i></i><i></i><i></i><i></i>
        </span>
        <span>${projectName}后台</span>
      </div>
      <h1>${projectName}</h1>
      <p>统一管理分类、内容、订单与用户数据</p>
    </section>
    <el-form ref="loginForm" :model="form" :rules="rules" class="login-card" @keyup.enter.native="handleLogin">
      <h2>欢迎登录${projectName}后台</h2>
      <p>请输入管理员账号进入后台</p>
      <el-form-item prop="username">
        <el-input v-model="form.username" prefix-icon="el-icon-user" placeholder="请输入账号" />
      </el-form-item>
      <el-form-item prop="password">
        <el-input v-model="form.password" prefix-icon="el-icon-lock" placeholder="请输入密码" show-password />
      </el-form-item>
      <div class="login-options">
        <el-checkbox v-model="form.remember">记住账号</el-checkbox>
      </div>
      <el-button type="primary" :loading="loading" @click="handleLogin">登录</el-button>
    </el-form>
  </div>
</template>

<script>
#if($hasTables)
#set($firstTable = $tables.get(0))
const defaultRedirect = "/${firstTable.moduleName}/${firstTable.businessName}"
#else
const defaultRedirect = "/empty"
#end

export default {
  name: "AdminLogin",
  data() {
    return {
      loading: false,
      form: {
        username: window.localStorage.getItem("adminUserName") || "admin",
        password: "[REDACTED]
        remember: true
      },
      rules: {
        username: [{ required: true, message: "请输入账号", trigger: "blur" }],
        password: [{ required: true, message: "请输入密码", trigger: "blur" }]
      }
    }
  },
  methods: {
    handleLogin() {
      this.#[[$refs]]#.loginForm.validate(valid => {
        if (!valid) {
          return
        }
        this.loading = true
        this.request.post("/auth/admin/login", {
          username: this.form.username,
          password: [REDACTED]
        }).then(res => {
          if (!res || res.code !== "200" || !res.data || !res.data.token) {
            throw new Error((res && res.msg) || "登录失败")
          }
          window.localStorage.setItem("admin-token", res.data.token)
          window.localStorage.setItem("admin-user", JSON.stringify(res.data.user || {
            username: this.form.username,
            nickname: this.form.username
          }))
          if (this.form.remember) {
            window.localStorage.setItem("adminUserName", this.form.username)
          } else {
            window.localStorage.removeItem("adminUserName")
          }
          this.#[[$message]]#.success("登录成功")
          this.#[[$router]]#.replace(this.#[[$route]]#.query.redirect || defaultRedirect)
        }).catch(error => {
          this.#[[$message]]#.error(error.message || "登录失败")
        }).finally(() => {
          this.loading = false
        })
      })
    }
  }
}
</script>

<style scoped>
.login-page {
  min-height: 100vh;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 420px;
  background: #f3f5fa;
}

.login-visual {
  padding: 54px 64px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  background: linear-gradient(135deg, #20a9b5 0%, #147a8f 48%, #001120 100%);
  color: #fff;
}

.login-brand {
  position: absolute;
  top: 32px;
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 20px;
  font-weight: 700;
}

.brand-mark {
  width: 30px;
  height: 30px;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 3px;
  transform: rotate(45deg);
}

.brand-mark i {
  display: block;
  border-radius: 2px;
}

.brand-mark i:nth-child(1) {
  background: #f4c542;
}

.brand-mark i:nth-child(2) {
  background: #22b8cf;
}

.brand-mark i:nth-child(3) {
  background: #2f80ed;
}

.brand-mark i:nth-child(4) {
  background: #42c77b;
}

.login-visual h1 {
  margin: 0;
  font-size: 46px;
  font-weight: 700;
  letter-spacing: 0;
}

.login-visual p {
  max-width: 520px;
  margin: 16px 0 0;
  color: rgba(255, 255, 255, .82);
  font-size: 18px;
}

.login-card {
  align-self: center;
  margin: 0 48px 0 0;
  padding: 36px 34px;
  border-radius: 8px;
  background: #fff;
  box-shadow: 0 18px 42px rgba(0, 17, 32, .16);
}

.login-card h2 {
  margin: 0;
  color: #17233d;
  font-size: 25px;
  font-weight: 700;
  letter-spacing: 0;
}

.login-card p {
  margin: 8px 0 24px;
  color: #8a95a6;
}

.login-options {
  margin: -4px 0 18px;
}

.login-card .el-button {
  width: 100%;
  height: 42px;
  border-color: #0f7dbd;
  background: #0f7dbd;
}

@media (max-width: 900px) {
  .login-page {
    grid-template-columns: 1fr;
  }

  .login-visual {
    min-height: 220px;
    padding: 36px 28px;
  }

  .login-brand {
    position: static;
    margin-bottom: 48px;
  }

  .login-visual h1 {
    font-size: 34px;
  }

  .login-card {
    margin: 24px;
  }
}
</style>
', 'admin', sysdate()),
(920208, 9202, 'request.js.vm', NULL, 'request.js.vm', 'import axios from "axios"
import { Message } from "element-ui"

const request = axios.create({
  baseURL: "/api",
  timeout: 30000
})

request.interceptors.request.use(config => {
  const token = window.localStorage.getItem("admin-token")
  if (token) {
    config.headers.Authorization = "Bearer " + token
  }
  return config
})

request.interceptors.response.use(
  response => {
    const data = response.data
    if (data && data.code === "401") {
      clearAdminSession()
      Message.error(data.msg || "请先登录")
      return Promise.reject(new Error(data.msg || "Unauthorized"))
    }
    return data
  },
  error => {
    if (error && error.response && error.response.status === 401) {
      clearAdminSession()
      Message.error("请先登录")
    } else {
      Message.error(error && error.message ? error.message : "请求失败")
    }
    return Promise.reject(error)
  }
)

function clearAdminSession() {
  window.localStorage.removeItem("admin-token")
  window.localStorage.removeItem("admin-user")
  if (window.location.hash.indexOf("#/login") !== 0) {
    window.location.hash = "#/login"
  }
}

export default request
', 'admin', sysdate()),
(920209, 9202, 'dict.js.vm', NULL, 'dict.js.vm', 'const emptyDict = []

export default {
  getDictDataByType() {
    return emptyDict
  },
  getDictLabel(type, value) {
    return value === undefined || value === null || value === "" ? "-" : value
  }
}
', 'admin', sysdate()),
(920210, 9202, 'admin-index.vue.vm', NULL, 'admin-index.vue.vm', '<template>
  <div class="page-container">
    <section class="query-panel">
      <div class="toolbar query-toolbar">
#foreach ($column in $columns)
#if($column.isQuery == "1")
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#if($column.htmlType == "input")
      <el-input class="filter-item" placeholder="请输入${columnComment}" clearable v-model="queryParams.${javaField}" @keyup.enter.native="load" />
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
      <el-select class="filter-item" v-model="queryParams.${javaField}" placeholder="请选择${columnComment}" clearable>
        <el-option
          v-for="item in _dict.getDictDataByType(''${column.dictType}'')"
          :key="item.value"
          :label="item.name"
          :value="item.value" />
      </el-select>
#elseif($column.htmlType == "datetime")
      <el-date-picker class="filter-item" v-model="queryParams.${javaField}" type="date" value-format="yyyy-MM-dd" placeholder="选择${columnComment}" />
#end
#end
#end
      <el-button type="primary" @click="load">搜索</el-button>
      <el-button @click="reset">重置</el-button>
      </div>
    </section>

    <section class="action-panel">
      <div class="toolbar action-toolbar">
      <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
      <el-popconfirm
        confirm-button-text="确定"
        cancel-button-text="取消"
        icon="el-icon-info"
        icon-color="red"
        title="确定批量删除选中的数据吗？"
        @confirm="delBatch">
        <el-button slot="reference" type="danger" icon="el-icon-delete">批量删除</el-button>
      </el-popconfirm>
      </div>
    </section>

    <section class="table-panel">
    <el-table class="data-table" :data="${businessName}List" stripe @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55" />
      <el-table-column prop="${pkColumn.javaField}" label="ID" width="90" />
#foreach ($column in $columns)
#if($column.isList == "1")
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#if($column.htmlType == "datetime")
      <el-table-column prop="${javaField}" label="${columnComment}" align="center" width="180" />
#elseif($column.htmlType == "imageUpload")
      <el-table-column prop="${javaField}" label="${columnComment}" align="center" width="110">
        <template slot-scope="scope">
          <el-image
            v-if="scope.row.${javaField}"
            class="table-image"
            :src="resolveResourceUrl(scope.row.${javaField})"
            :preview-src-list="[resolveResourceUrl(scope.row.${javaField})]" />
          <span v-else>-</span>
        </template>
      </el-table-column>
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
      <el-table-column prop="${javaField}" label="${columnComment}" align="center">
        <template slot-scope="scope">
          {{ _dict.getDictLabel(''${column.dictType}'', scope.row.${javaField}) }}
        </template>
      </el-table-column>
#else
      <el-table-column prop="${javaField}" label="${columnComment}" align="center" />
#end
#end
#end
      <el-table-column label="操作" width="190" align="center">
        <template slot-scope="scope">
          <el-button class="row-action edit-action" type="primary" icon="el-icon-edit" circle @click="handleEdit(scope.row)"></el-button>
          <el-popconfirm
            confirm-button-text="确定"
            cancel-button-text="取消"
            icon="el-icon-info"
            icon-color="red"
            title="确定删除这条数据吗？"
            @confirm="del(scope.row.${pkColumn.javaField})">
            <el-button slot="reference" class="row-action delete-action" type="danger" icon="el-icon-delete" circle></el-button>
          </el-popconfirm>
        </template>
      </el-table-column>
    </el-table>
    </section>

    <section class="pagination-panel">
    <el-pagination
      class="pagination"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
      :current-page="queryParams.pageNum"
      :page-sizes="[10, 20, 50, 100]"
      :page-size="queryParams.pageSize"
      layout="total, sizes, prev, pager, next, jumper"
      :total="total" />
    </section>

    <el-dialog :title="form.${pkColumn.javaField} ? ''编辑${functionName}'' : ''新增${functionName}''" :visible.sync="dialogFormVisible" width="520px">
      <el-form ref="form" :model="form" label-width="110px" size="small">
#foreach ($column in $columns)
#if($column.isEdit == "1")
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
        <el-form-item label="${columnComment}" prop="${javaField}"#if($column.isRequired == "1") :rules="[{ required: true, message: ''${columnComment}不能为空'', trigger: #if($column.htmlType == "select")''change''#else''blur''#end }]"#end>
#if($column.htmlType == "input")
          <el-input v-model="form.${javaField}" placeholder="请输入${columnComment}" />
#elseif($column.htmlType == "textarea")
          <el-input v-model="form.${javaField}" type="textarea" placeholder="请输入${columnComment}" />
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
          <el-select v-model="form.${javaField}" placeholder="请选择${columnComment}" clearable>
            <el-option
              v-for="item in _dict.getDictDataByType(''${column.dictType}'')"
              :key="item.value"
              :label="item.name"
              :value="item.value" />
          </el-select>
#elseif($column.htmlType == "datetime")
          <el-date-picker v-model="form.${javaField}" type="date" value-format="yyyy-MM-dd" placeholder="选择${columnComment}" />
#elseif($column.htmlType == "imageUpload")
          <el-upload
            class="avatar-uploader"
            action="/api/files/upload"
            :show-file-list="false"
            :on-success="(res) => uploadSuccess(''${javaField}'', res)">
            <img v-if="form.${javaField}" :src="resolveResourceUrl(form.${javaField})" class="avatar" />
            <i v-else class="el-icon-plus avatar-uploader-icon"></i>
          </el-upload>
#elseif($column.htmlType == "fileUpload")
          <el-upload
            action="/api/files/upload"
            :on-success="(res) => uploadSuccess(''${javaField}'', res)">
            <el-button size="small" type="primary">点击上传</el-button>
          </el-upload>
          <div v-if="form.${javaField}" class="file-link">{{ form.${javaField} }}</div>
#end
        </el-form-item>
#end
#end
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogFormVisible = false">取消</el-button>
        <el-button type="primary" @click="save">确定</el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
export default {
  name: "${BusinessName}",
  data() {
    return {
      ${businessName}List: [],
      total: 0,
      queryParams: {
        pageNum: 1,
        pageSize: 10,
#foreach ($column in $columns)
#if($column.isQuery == "1")
        ${column.javaField}: undefined,
#end
#end
      },
      form: {},
      dialogFormVisible: false,
      multipleSelection: []
    }
  },
  created() {
    this.load()
  },
  methods: {
    load() {
      this.request.get("/${moduleName}/${businessName}/page", {
        params: this.queryParams
      }).then(res => {
        const page = res.data || {}
        this.${businessName}List = page.records || []
        this.total = page.total || 0
      })
    },
    save() {
      this.#[[$refs]]#.form.validate(valid => {
        if (!valid) {
          return
        }
        this.request.post("/${moduleName}/${businessName}", this.form).then(res => {
          if (res.code === "200") {
            this.#[[$message]]#.success("保存成功")
            this.dialogFormVisible = false
            this.load()
          } else {
            this.#[[$message]]#.error(res.msg || "保存失败")
          }
        })
      })
    },
    handleAdd() {
      this.form = {}
      this.dialogFormVisible = true
      this.#[[$nextTick]]#(() => this.#[[$refs]]#.form && this.#[[$refs]]#.form.clearValidate())
    },
    handleEdit(row) {
      this.form = JSON.parse(JSON.stringify(row))
      this.dialogFormVisible = true
      this.#[[$nextTick]]#(() => this.#[[$refs]]#.form && this.#[[$refs]]#.form.clearValidate())
    },
    del(${pkColumn.javaField}) {
      this.request.delete("/${moduleName}/${businessName}/" + ${pkColumn.javaField}).then(res => {
        if (res.code === "200") {
          this.#[[$message]]#.success("删除成功")
          this.load()
        } else {
          this.#[[$message]]#.error(res.msg || "删除失败")
        }
      })
    },
    handleSelectionChange(val) {
      this.multipleSelection = val
    },
    delBatch() {
      const ids = this.multipleSelection.map(item => item.${pkColumn.javaField})
      if (!ids.length) {
        this.#[[$message]]#.warning("请先选择要删除的数据")
        return
      }
      this.request.post("/${moduleName}/${businessName}/del/batch", ids).then(res => {
        if (res.code === "200") {
          this.#[[$message]]#.success("批量删除成功")
          this.load()
        } else {
          this.#[[$message]]#.error(res.msg || "批量删除失败")
        }
      })
    },
    reset() {
      this.queryParams = {
        pageNum: 1,
        pageSize: 10
      }
      this.load()
    },
    handleSizeChange(pageSize) {
      this.queryParams.pageSize = pageSize
      this.load()
    },
    handleCurrentChange(pageNum) {
      this.queryParams.pageNum = pageNum
      this.load()
    },
    resolveResourceUrl(url) {
      if (!url) {
        return ""
      }
      if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0 || url.indexOf("//") === 0 || url.indexOf("data:") === 0 || url.indexOf("blob:") === 0) {
        return url
      }
      if (url.indexOf("/api/") === 0) {
        return url
      }
      if (url.indexOf("/files/") === 0) {
        return "/api" + url
      }
      return url
    },
    uploadSuccess(field, res) {
      if (res && res.code === "200" && res.data && res.data.url) {
        this.#[[$set]]#(this.form, field, res.data.url)
      } else {
        this.#[[$message]]#.error("上传失败，请重试")
      }
    }
  }
}
</script>

<style scoped>
.page-container {
  min-height: calc(100vh - 72px);
}

.query-panel,
.action-panel,
.table-panel,
.pagination-panel {
  margin-bottom: 8px;
  border: 1px solid #edf0f5;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 1px 8px rgba(26, 39, 56, .08);
}

.query-panel,
.action-panel {
  padding: 12px 14px;
}

.table-panel {
  padding: 12px 12px 0;
}

.pagination-panel {
  padding: 10px 12px;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.query-toolbar .el-button--primary {
  border-color: #cde8f0;
  background: #eaf8fb;
  color: #158a9a;
}

.query-toolbar .el-button--default {
  border-color: #f6e6ba;
  background: #fff8e6;
  color: #c58600;
}

.action-toolbar .el-button--primary {
  border-color: #9cd4ed;
  background: #eefaff;
  color: #0d80bd;
}

.action-toolbar .el-button--danger {
  border-color: #f2c2c7;
  background: #fff3f4;
  color: #d64252;
}

.filter-item {
  width: 220px;
}

.data-table {
  width: 100%;
  color: #606266;
  font-size: 13px;
}

.data-table >>> th {
  height: 48px;
  background: #fff;
  color: #7d8794;
  font-weight: 600;
}

.data-table >>> td {
  height: 58px;
  border-color: #f0f2f5;
}

.data-table >>> .el-table__row--striped td {
  background: #fafafa;
}

.pagination {
  display: flex;
  justify-content: flex-start;
}

.row-action {
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  font-size: 14px;
}

.edit-action {
  background: #0b7ec2;
}

.delete-action {
  margin-left: 10px;
  background: #e82f3d;
}

.table-image {
  width: 52px;
  height: 52px;
  border-radius: 4px;
}

.avatar-uploader .avatar {
  width: 96px;
  height: 96px;
  display: block;
  object-fit: cover;
}

.avatar-uploader-icon {
  width: 96px;
  height: 96px;
  line-height: 96px;
  border: 1px dashed #d9d9d9;
  border-radius: 4px;
  text-align: center;
  color: #8c939d;
}

.file-link {
  margin-top: 6px;
  color: #606266;
  word-break: break-all;
}
</style>
', 'admin', sysdate());

-- Portal frontend template files
INSERT INTO sys_template_file (template_file_id, template_id, file_name, module_id, file_path, file_content, create_by, create_time) VALUES
(920301, 9203, 'package.json.vm', NULL, 'package.json.vm', '{
  "name": "${projectFileName}-web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vue-cli-service serve --host 0.0.0.0",
    "serve": "vue-cli-service serve --host 0.0.0.0",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "axios": "^0.27.2",
    "core-js": "^3.8.3",
    "echarts": "5.4.0",
    "element-ui": "^2.15.14",
    "vue": "^2.6.14",
    "vue-router": "^3.6.5"
  },
  "devDependencies": {
    "@vue/cli-service": "^4.5.19",
    "vue-template-compiler": "^2.6.14"
  }
}
', 'admin', sysdate()),
(920302, 9203, 'babel.config.js.vm', NULL, 'babel.config.js.vm', 'module.exports = {
  presets: [
    "@vue/cli-plugin-babel/preset"
  ]
}
', 'admin', sysdate()),
(920303, 9203, 'vue.config.js.vm', NULL, 'vue.config.js.vm', 'const port = process.env.PORT || 8082
const apiBaseUrl = process.env.VUE_APP_API_BASE_URL || "http://localhost:8080"

module.exports = {
  transpileDependencies: [],
  devServer: {
    port,
    proxy: {
      "/api": {
        target: apiBaseUrl,
        changeOrigin: true,
        pathRewrite: {
          "^/api": ""
        }
      }
    }
  }
}
', 'admin', sysdate()),
(920304, 9203, 'index.html.vm', NULL, 'index.html.vm', '<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <title>${functionName}</title>
</head>
<body>
  <noscript>请启用 JavaScript 后访问。</noscript>
  <div id="app"></div>
</body>
</html>
', 'admin', sysdate()),
(920305, 9203, 'frontend-main.js.vm', NULL, 'frontend-main.js.vm', 'import Vue from "vue"
import ElementUI from "element-ui"
import "element-ui/lib/theme-chalk/index.css"
import App from "./App.vue"
import router from "./router"
import request from "./api/request"
import dict from "./utils/dict"
import "./style.css"

Vue.config.productionTip = false
Vue.use(ElementUI)
Vue.prototype.request = request
Vue.prototype._dict = dict

new Vue({
  router,
  render: h => h(App)
}).#[[$mount]]#("#app")', 'admin', sysdate()),
(920306, 9203, 'frontend-App.vue.vm', NULL, 'frontend-App.vue.vm', '#set($portalProjectName = $projectName)
#if(!$portalProjectName || $portalProjectName == "")
#set($portalProjectName = $functionName)
#end
#if(!$portalProjectName || $portalProjectName == "")
#set($portalProjectName = "Portal")
#end
#set($portalHomePath = $defaultFrontendPath)
#if(!$portalHomePath || $portalHomePath == "")
#set($portalHomePath = "/${moduleName}/${businessName}")
#end
<template>
  <div class="portal-shell">
    <template v-if="!isLoginRoute">
      <header class="top-nav">
        <router-link class="brand" to="${portalHomePath}">
          <span class="brand-logo" aria-hidden="true">APP</span>
          <span>${portalProjectName}</span>
        </router-link>

        <nav class="portal-nav" aria-label="主导航">
#if($hasFrontendNavGroups)
#foreach($group in $frontendNavGroups)
          <div class="portal-nav-group#if($group.hasChildren) has-children#end" :class=''{ active: isNavGroupActive(${group.activePathsLiteral}) }''>
#if($group.clickable)
            <router-link class="portal-nav-parent" to="${group.path}" exact>${group.name}</router-link>
#else
            <span class="portal-nav-parent portal-nav-directory">${group.name}</span>
#end
#if($group.hasChildren)
            <button class="portal-nav-toggle" type="button" aria-label="展开子菜单">⌄</button>
            <div class="portal-nav-dropdown">
#foreach($child in $group.children)
              <router-link class="portal-nav-child" to="${child.path}" exact>${child.name}</router-link>
#end
            </div>
#end
          </div>
#end
#else
          <div class="portal-nav-group">
            <router-link class="portal-nav-parent" to="${portalHomePath}" exact>首页</router-link>
          </div>
#end
        </nav>

        <div class="user-area">
          <template v-if="currentUser">
            <button class="user-chip" type="button">
              <span class="avatar">{{ userInitial }}</span>
              <span>{{ currentUserName }}</span>
            </button>
            <button class="logout-link" type="button" @click="logout">退出</button>
          </template>
          <button v-else class="login-link" type="button" @click="goLogin">登录</button>
        </div>
      </header>

      <section class="hero-banner" aria-label="项目横幅">
        <div class="hero-pattern" aria-hidden="true"></div>
        <h1>${portalProjectName}</h1>
      </section>
    </template>

    <main :class="[''portal-main'', { ''portal-main-login'': isLoginRoute }]">
      <router-view />
    </main>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      currentUser: null
    }
  },
  computed: {
    isLoginRoute() {
      return this.#[[$route]]#.path === "/login"
    },
    currentUserName() {
      return this.currentUser && (this.currentUser.nickname || this.currentUser.username || "User")
    },
    userInitial() {
      const name = this.currentUserName || "U"
      return name.slice(0, 1).toUpperCase()
    }
  },
  mounted() {
    this.loadUser()
    window.addEventListener("portal-user-change", this.loadUser)
  },
  beforeDestroy() {
    window.removeEventListener("portal-user-change", this.loadUser)
  },
  methods: {
    isNavGroupActive(paths) {
      return Array.isArray(paths) && paths.indexOf(this.#[[$route]]#.path) !== -1
    },
    loadUser() {
      const raw = localStorage.getItem("portal-user")
      if (!raw) {
        this.currentUser = null
        return
      }
      try {
        this.currentUser = JSON.parse(raw)
      } catch (error) {
        localStorage.removeItem("portal-user")
        this.currentUser = null
      }
    },
    goLogin() {
      this.#[[$router]]#.push({
        path: "/login",
        query: { redirect: this.#[[$route]]#.fullPath }
      })
    },
    logout() {
      this.request.post("/auth/logout").catch(() => {})
      localStorage.removeItem("portal-token")
      localStorage.removeItem("portal-user")
      window.dispatchEvent(new Event("portal-user-change"))
      this.#[[$router]]#.push("/login")
    }
  }
}
</script>

<style>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: #f6f7fb;
  color: #1f2937;
  font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
}

.portal-shell {
  min-height: 100vh;
  background: #f6f7fb;
}

.top-nav {
  position: sticky;
  top: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: minmax(180px, auto) minmax(0, 1fr) auto;
  align-items: center;
  gap: 24px;
  min-height: 68px;
  padding: 0 32px;
  color: #111827;
  background: #ffffff;
  border-bottom: 1px solid #e5e7eb;
  box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}

.brand,
.portal-nav,
.user-area,
.user-chip {
  display: inline-flex;
  align-items: center;
}

.brand {
  gap: 10px;
  min-width: 0;
  font-size: 18px;
  font-weight: 700;
}

.brand span:last-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.brand-logo {
  display: inline-grid;
  width: 36px;
  height: 36px;
  flex: 0 0 36px;
  place-items: center;
  border-radius: 8px;
  color: #ffffff;
  background: #17633b;
  font-size: 12px;
  font-weight: 800;
  letter-spacing: 0;
}

.portal-nav {
  justify-content: center;
  gap: 8px;
  min-width: 0;
  height: 100%;
  overflow: visible;
}

.portal-nav-group {
  position: relative;
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  min-height: 68px;
}

.portal-nav-parent,
.portal-nav-toggle,
.portal-nav-child {
  display: inline-flex;
  align-items: center;
  border: 0;
  background: transparent;
  font: inherit;
  cursor: pointer;
}

.portal-nav-parent {
  min-height: 36px;
  padding: 0 14px;
  border-radius: 8px;
  color: #1f2937;
  font-size: 15px;
  font-weight: 600;
  white-space: nowrap;
}

.portal-nav-parent:hover,
.portal-nav-parent.router-link-active,
.portal-nav-group.active .portal-nav-parent {
  color: #2563eb;
  background: #eef5ff;
}

.portal-nav-directory {
  cursor: default;
}

.portal-nav-toggle {
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-left: -8px;
  border-radius: 6px;
  color: #64748b;
}

.portal-nav-group:hover .portal-nav-toggle,
.portal-nav-group:focus-within .portal-nav-toggle,
.portal-nav-group.active .portal-nav-toggle {
  color: #2563eb;
  background: #eef5ff;
}

.portal-nav-dropdown {
  position: absolute;
  top: calc(100% - 8px);
  left: 0;
  z-index: 30;
  display: none;
  min-width: 168px;
  padding: 8px;
  border: 1px solid #dbe4f0;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0 16px 34px rgba(15, 23, 42, 0.14);
}

.portal-nav-group:hover .portal-nav-dropdown,
.portal-nav-group:focus-within .portal-nav-dropdown {
  display: grid;
  gap: 4px;
}

.portal-nav-child {
  min-height: 34px;
  padding: 0 10px;
  border-radius: 6px;
  color: #334155;
  font-size: 14px;
  white-space: nowrap;
}

.portal-nav-child:hover,
.portal-nav-child.router-link-active {
  color: #2563eb;
  background: #eef5ff;
}

.user-area {
  justify-content: flex-end;
  gap: 10px;
  min-width: 130px;
}

.user-chip,
.login-link,
.logout-link {
  border: 1px solid #dbe4f0;
  color: #475569;
  background: #ffffff;
  cursor: pointer;
}

.user-chip {
  gap: 8px;
  padding: 0;
}

.avatar {
  display: inline-grid;
  width: 36px;
  height: 36px;
  place-items: center;
  border: 1px solid #bfdbfe;
  border-radius: 50%;
  color: #1d4ed8;
  background: #eff6ff;
  font-weight: 700;
}

.logout-link,
.login-link {
  min-height: 32px;
  padding: 0 10px;
  border-radius: 4px;
}

.logout-link:hover,
.login-link:hover {
  color: #2563eb;
  background: #f8fbff;
}

.hero-banner {
  position: relative;
  display: grid;
  min-height: 260px;
  place-items: center;
  overflow: hidden;
  background:
    linear-gradient(132deg, rgba(24, 141, 149, 0.95), rgba(44, 120, 215, 0.88)),
    #168d95;
}

.hero-pattern {
  position: absolute;
  inset: 0;
  opacity: 0.58;
  background:
    linear-gradient(42deg, transparent 0 18%, rgba(255, 255, 255, 0.18) 18% 27%, transparent 27% 100%),
    linear-gradient(132deg, transparent 0 48%, rgba(246, 186, 51, 0.32) 48% 55%, transparent 55% 100%);
}

.hero-banner h1 {
  position: relative;
  z-index: 1;
  max-width: min(900px, calc(100% - 40px));
  margin: 0;
  color: #ffffff;
  font-size: 46px;
  font-weight: 600;
  letter-spacing: 0;
  line-height: 1.2;
  text-align: center;
  text-shadow: 0 12px 28px rgba(15, 23, 42, 0.22);
}

.portal-main {
  width: min(1120px, calc(100% - 48px));
  margin: 0 auto;
  padding: 26px 0 44px;
}

.portal-main-login {
  width: 100%;
  min-height: 100vh;
  margin: 0;
  padding: 0;
}

@media (max-width: 900px) {
  .top-nav {
    grid-template-columns: 1fr auto;
    gap: 10px;
    height: auto;
    min-height: 56px;
    padding: 10px 16px;
  }

  .portal-nav {
    grid-column: 1 / -1;
    justify-content: flex-start;
    order: 3;
    gap: 6px;
    overflow-x: auto;
    overflow-y: visible;
  }

  .portal-nav-group {
    min-height: 38px;
  }

  .portal-nav-parent {
    min-height: 34px;
    padding: 0 12px;
  }

  .portal-nav-dropdown {
    position: fixed;
    top: 104px;
    left: 16px;
    right: 16px;
  }

  .hero-banner {
    min-height: 210px;
  }

  .hero-banner h1 {
    font-size: 34px;
  }

  .portal-main {
    width: min(100% - 28px, 1120px);
  }
}
</style>
', 'admin', sysdate()),
(920307, 9203, 'style.css.vm', NULL, 'style.css.vm', 'body {
  margin: 0;
  background: #f6f7fb;
  color: #1f2937;
  font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}

.el-button {
  border-radius: 4px;
}', 'admin', sysdate()),
(920308, 9203, 'frontend-router.js.vm', NULL, 'frontend-router.js.vm', 'import Vue from "vue"
import Router from "vue-router"
import Login from "@/views/login/index.vue"
#set($portalDefaultPath = $defaultFrontendPath)
#if(!$portalDefaultPath || $portalDefaultPath == "")
#set($portalDefaultPath = "/${moduleName}/${businessName}")
#end
#if($hasFrontendRouteItems)
#foreach($component in $frontendRouteComponents)
import ${component.componentName} from "${component.componentPath}"
#end
#else
import ${BusinessName} from "@/views/${moduleName}/${businessName}/index.vue"
#end

Vue.use(Router)

const router = new Router({
  mode: "hash",
  routes: [
    {
      path: "/",
      redirect: "${portalDefaultPath}"
    },
    {
      path: "/login",
      name: "Login",
      component: Login
    },
#if($hasFrontendRouteItems)
#foreach($item in $frontendRouteItems)
    {
      path: "${item.path}",
      name: "${item.code}",
      component: ${item.componentName},
      meta: { requiresAuth: ${item.requiresLogin} }
    }#if($foreach.hasNext),#end
#end
#else
    {
      path: "/${moduleName}/${businessName}",
      name: "${BusinessName}",
      component: ${BusinessName},
      meta: { requiresAuth: true }
    }
#end
  ]
})

router.beforeEach((to, from, next) => {
  const requiresAuth = to.matched.some(record => record.meta && record.meta.requiresAuth)
  const hasToken = Boolean(localStorage.getItem("portal-token"))

  if (requiresAuth && !hasToken) {
    next({
      path: "/login",
      query: { redirect: to.fullPath }
    })
    return
  }

  next()
})

export default router
', 'admin', sysdate()),
(920309, 9203, 'frontend-request.js.vm', NULL, 'frontend-request.js.vm', 'import axios from "axios"
import { Message } from "element-ui"

const request = axios.create({
  baseURL: "/api",
  timeout: 30000
})

request.interceptors.request.use(config => {
  const token = localStorage.getItem("portal-token")
  if (token) {
    config.headers.Authorization = "Bearer " + token
  }
  return config
})

request.interceptors.response.use(
  response => {
    const data = response.data
    if (data && data.code === "401") {
      clearSession()
      Message.error(data.msg || "请先登录")
      return Promise.reject(new Error(data.msg || "Unauthorized"))
    }
    return data
  },
  error => {
    if (error && error.response && error.response.status === 401) {
      clearSession()
      Message.error("请先登录")
    } else {
      Message.error(error && error.message ? error.message : "请求失败")
    }
    return Promise.reject(error)
  }
)

function clearSession() {
  localStorage.removeItem("portal-token")
  localStorage.removeItem("portal-user")
  if (window.location.hash.indexOf("#/login") !== 0) {
    window.location.hash = "#/login"
  }
}

export default request
', 'admin', sysdate()),
(920310, 9203, 'frontend-api.js.vm', NULL, 'frontend-api.js.vm', 'import request from "./request"

export function list${ClassName}(query) {
  return request({
    url: "/${moduleName}/${businessName}/page",
    method: "get",
    params: query
  })
}

export function get${ClassName}(${pkColumn.javaField}) {
  return request({
    url: "/${moduleName}/${businessName}/" + ${pkColumn.javaField},
    method: "get"
  })
}', 'admin', sysdate()),
(920311, 9203, 'frontend-index.vue.vm', NULL, 'frontend-index.vue.vm', '<template>
#set($designed = $hasCurrentFrontendPageDesign)
#set($queryColumns = $columns)
#set($tableColumns = $columns)
#set($formColumns = $columns)
#set($cardColumns = $columns)
#set($cardTitleColumn = false)
#set($cardImageColumn = false)
#set($showAdd = false)
#set($showBatchDelete = false)
#set($showView = false)
#set($showEdit = false)
#set($showDelete = false)
#set($isCardListLayout = false)
#set($isSplitListLayout = false)
#set($showQuery = true)
#set($showToolbar = true)
#set($showForm = true)
#set($renderForm = true)
#set($currentUserOnly = false)
#set($isDetailPage = $pageDesignIsDetail)
#set($detailColumns = $columns)
#set($toolbarBusinessActions = $pageDesignToolbarBusinessActions)
#set($rowBusinessActions = $pageDesignRowBusinessActions)
#set($allPageBusinessActions = $pageDesignBusinessActions)
#set($hasToolbarBusinessActions = $hasPageDesignToolbarBusinessActions)
#set($hasRowBusinessActions = $hasPageDesignRowBusinessActions)
#set($topBusinessBlocks = $pageDesignTopBusinessBlocks)
#set($bottomBusinessBlocks = $pageDesignBottomBusinessBlocks)
#set($embeddedBusinessBlocks = $pageDesignEmbeddedBusinessBlocks)
#if($allowAdd == ''1'')
#set($showAdd = true)
#end
#if($allowRemove == ''1'')
#set($showBatchDelete = true)
#set($showDelete = true)
#end
#if($allowEdit == ''1'')
#set($showEdit = true)
#end
#if($designed)
#set($queryColumns = $pageDesignQueryColumns)
#set($tableColumns = $pageDesignTableColumns)
#set($formColumns = $pageDesignFormColumns)
#set($cardColumns = $pageDesignCardColumns)
#set($cardTitleColumn = $pageDesignCardTitleColumn)
#set($cardImageColumn = $pageDesignCardImageColumn)
#set($showAdd = $pageDesignAllowAdd)
#set($showBatchDelete = $pageDesignAllowBatchDelete)
#set($showView = $pageDesignAllowView)
#set($showEdit = $pageDesignAllowEdit)
#set($showDelete = $pageDesignAllowDelete)
#set($isCardListLayout = $pageDesignIsCardList)
#set($isSplitListLayout = $pageDesignIsSplitList)
#set($showQuery = $pageDesignShowQuery)
#set($showToolbar = $pageDesignShowToolbar)
#set($showForm = $pageDesignShowForm)
#set($currentUserOnly = $pageDesignIsCurrentUserList)
#set($detailColumns = $pageDesignFormColumns)
#end
#if(!$showToolbar)
#set($showAdd = false)
#set($showBatchDelete = false)
#end
#if($isDetailPage)
#set($showQuery = false)
#set($showToolbar = false)
#set($showAdd = false)
#set($showBatchDelete = false)
#set($showView = false)
#set($showEdit = false)
#set($showDelete = false)
#set($showForm = false)
#set($renderForm = false)
#if($detailColumns.size() == 0)
#set($detailColumns = $columns)
#end
#end
#set($renderForm = $showForm)
#if($showAdd || $showEdit)
#set($renderForm = true)
#end
  <section class="portal-page">
#if($hasPageDesignTopBusinessBlocks)
    <section class="embedded-business-blocks top-business-blocks">
#foreach($block in $topBusinessBlocks)
      <div class="embedded-business-block__item embedded-business-block__item--span-${block.span}">
        <${block.componentName} />
      </div>
#end
    </section>
#end

    <section class="content-board">
      <div class="feature-card">
        <div class="feature-illustration" aria-hidden="true">
          <div class="device-face">
            <span></span>
            <span></span>
          </div>
        </div>
        <div class="feature-copy">
          <span>精选推荐</span>
          <h2>${functionName}</h2>
          <p>围绕资源管理、活动公告和玩家社区内容，快速维护当前模块的数据。</p>
        </div>
      </div>

      <aside class="notice-panel">
        <div class="notice-tabs">
          <button
            v-for="tab in noticeTabs"
            :key="tab"
            type="button"
            :class="{ active: activeTab === tab }"
            @click="activeTab = tab">
            {{ tab }}
          </button>
        </div>
        <ul class="notice-list">
          <li v-for="notice in notices" :key="notice.title">
            <strong>{{ notice.title }}</strong>
            <span><i class="el-icon-time"></i>{{ notice.time }}</span>
          </li>
        </ul>
      </aside>
    </section>

    <section class="data-panel#if($isSplitListLayout) split-data-panel#end">
      <div class="panel-heading">
        <div>
          <span class="section-kicker">资源数据</span>
          <h2>${functionName}管理</h2>
        </div>
        <div class="panel-actions">
          #if($showToolbar)
          #if($showAdd)
          <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
          #end
          #if($showBatchDelete)
          <el-popconfirm
            confirm-button-text="确定"
            cancel-button-text="取消"
            icon="el-icon-info"
            icon-color="red"
            title="确定批量删除选中的数据吗？"
            @confirm="delBatch">
            <el-button slot="reference" type="danger" icon="el-icon-delete">批量删除</el-button>
          </el-popconfirm>
          #end
          #foreach($action in $toolbarBusinessActions)
          #set($buttonLabel = $action.buttonLabel)
          #if(!$buttonLabel)#set($buttonLabel = $action.name)#end
          #set($buttonType = $action.buttonType)
          #if(!$buttonType)#set($buttonType = "primary")#end
          #set($buttonIcon = $action.buttonIcon)
          #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end
          <el-button type="${buttonType}" plain icon="${buttonIcon}" @click="handleBusinessAction(''${action.code}'')">${buttonLabel}</el-button>
          #end
          #end
        </div>
      </div>

#if($isDetailPage)
      <div class="detail-view" v-loading="detailLoading">
        <div v-if="detailLoaded && !detailRecord.${pkColumn.javaField}" class="empty-detail">
          <i class="el-icon-document"></i>
          <span>暂无详情数据</span>
        </div>
        <template v-else>
          <dl class="detail-grid">
#foreach ($column in $detailColumns)
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#set($remoteSelect=$qingRemoteSelectFields.contains($javaField))
            <div class="detail-field#if($column.htmlType == "textarea") wide#end">
              <dt>${columnComment}</dt>
              <dd>
#if($column.htmlType == "imageUpload")
                <el-image
                  v-if="detailRecord.${javaField}"
                  class="detail-image"
                  :src="resolveResourceUrl(detailRecord.${javaField})"
                  :preview-src-list="[resolveResourceUrl(detailRecord.${javaField})]" />
                <span v-else>-</span>
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
#if($remoteSelect)
                {{ getOptionLabel(''${javaField}'', detailRecord.${javaField}) }}
#else
                {{ _dict.getDictLabel(''${column.dictType}'', detailRecord.${javaField}) || ''-'' }}
#end
#else
                {{ formatDetailValue(detailRecord.${javaField}) }}
#end
              </dd>
            </div>
#end
          </dl>
#if($hasRowBusinessActions)
          <div class="detail-actions">
#foreach($action in $rowBusinessActions)
#set($buttonLabel = $action.buttonLabel)
#if(!$buttonLabel)#set($buttonLabel = $action.name)#end
#set($buttonType = $action.buttonType)
#if(!$buttonType)#set($buttonType = "primary")#end
#set($buttonIcon = $action.buttonIcon)
#if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end
            <el-button type="${buttonType}" icon="${buttonIcon}" @click="handleBusinessAction(''${action.code}'', detailRecord)">${buttonLabel}</el-button>
#end
          </div>
#end
        </template>
      </div>
#else

      #if($showQuery)
      <div class="filter-bar">
#foreach ($column in $queryColumns)
#set($renderQueryField = false)
#if($designed)
#set($renderQueryField = true)
#elseif($column.isQuery == "1")
#set($renderQueryField = true)
#end
#if($renderQueryField)
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#set($remoteSelect=$qingRemoteSelectFields.contains($javaField))
#if($column.htmlType == "input")
        <el-input class="filter-item" placeholder="请输入${columnComment}" clearable v-model="queryParams.${javaField}" @keyup.enter.native="load" />
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
        <el-select class="filter-item" v-model="queryParams.${javaField}" placeholder="请选择${columnComment}" clearable>
#if($remoteSelect)
          <el-option
            v-for="item in getSelectOptions(''${javaField}'')"
            :key="item.value"
            :label="item.label"
            :value="item.value" />
#else
          <el-option
            v-for="item in _dict.getDictDataByType(''${column.dictType}'')"
            :key="item.value"
            :label="item.name"
            :value="item.value" />
#end
        </el-select>
#elseif($column.htmlType == "datetime")
        <el-date-picker class="filter-item" v-model="queryParams.${javaField}" type="date" value-format="yyyy-MM-dd" placeholder="选择${columnComment}" />
#end
#end
#end
        <el-button type="primary" icon="el-icon-search" @click="load">搜索</el-button>
        <el-button icon="el-icon-refresh" @click="reset">重置</el-button>
      </div>
      #end

#if($isCardListLayout)
      <div class="resource-card-grid">
        <article v-for="item in ${businessName}List" :key="item.${pkColumn.javaField}" class="resource-card-item">
          #if($showBatchDelete)
          <el-checkbox class="resource-card-check" v-model="item.__checked" @change="handleCardSelectionChange" />
          #end
          <div class="resource-card-body">
#if($cardImageColumn)
            <el-image
              v-if="item.${cardImageColumn.javaField}"
              class="resource-card-image"
              :src="resolveResourceUrl(item.${cardImageColumn.javaField})"
              :preview-src-list="[resolveResourceUrl(item.${cardImageColumn.javaField})]" />
#end
#if($cardTitleColumn)
            <h3 class="resource-card-title">{{ item.${cardTitleColumn.javaField} || ''-'' }}</h3>
#end
#foreach ($column in $cardColumns)
#set($renderCardField = false)
#if($designed)
#set($renderCardField = true)
#elseif($column.isList == "1")
#set($renderCardField = true)
#end
#if($renderCardField)
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#set($remoteSelect=$qingRemoteSelectFields.contains($javaField))
#if($column.htmlType == "imageUpload")
            <el-image
              v-if="item.${javaField}"
              class="resource-card-image"
              :src="resolveResourceUrl(item.${javaField})"
              :preview-src-list="[resolveResourceUrl(item.${javaField})]" />
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
            <div class="resource-card-field">
              <span>${columnComment}</span>
#if($remoteSelect)
              <strong>{{ getOptionLabel(''${javaField}'', item.${javaField}) }}</strong>
#else
              <strong>{{ _dict.getDictLabel(''${column.dictType}'', item.${javaField}) || ''-'' }}</strong>
#end
            </div>
#else
            <div class="resource-card-field">
              <span>${columnComment}</span>
              <strong>{{ item.${javaField} || ''-'' }}</strong>
            </div>
#end
#end
#end
          </div>
          #if($showView || $showEdit || $showDelete || $hasRowBusinessActions)
          <div class="resource-card-actions">
            #if($showView)
            <el-button type="text" icon="el-icon-view" @click="handleView(item)">查看</el-button>
            #end
            #if($showEdit)
            <el-button type="text" icon="el-icon-edit" @click="handleEdit(item)">编辑</el-button>
            #end
            #if($showDelete)
            <el-popconfirm
              confirm-button-text="确定"
              cancel-button-text="取消"
              icon="el-icon-info"
              icon-color="red"
              title="确定删除这条数据吗？"
              @confirm="del(item.${pkColumn.javaField})">
              <el-button slot="reference" type="text" icon="el-icon-delete" class="danger-text">删除</el-button>
            </el-popconfirm>
            #end
            #foreach($action in $rowBusinessActions)
            #set($buttonLabel = $action.buttonLabel)
            #if(!$buttonLabel)#set($buttonLabel = $action.name)#end
            #set($buttonType = $action.buttonType)
            #if(!$buttonType)#set($buttonType = "text")#end
            #set($buttonIcon = $action.buttonIcon)
            #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end
            <el-button type="${buttonType}" icon="${buttonIcon}" @click="handleBusinessAction(''${action.code}'', item)">${buttonLabel}</el-button>
            #end
          </div>
          #end
        </article>
      </div>
#else
      <el-table :data="${businessName}List" border stripe class="resource-table" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="55" />
#if(!$designed)
        <el-table-column prop="${pkColumn.javaField}" label="ID" width="90" />
#end
#foreach ($column in $tableColumns)
#set($renderTableField = false)
#if($designed)
#set($renderTableField = true)
#elseif($column.isList == "1")
#set($renderTableField = true)
#end
#if($renderTableField)
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#set($remoteSelect=$qingRemoteSelectFields.contains($javaField))
#if($column.htmlType == "datetime")
        <el-table-column prop="${javaField}" label="${columnComment}" align="center" width="180" />
#elseif($column.htmlType == "imageUpload")
        <el-table-column prop="${javaField}" label="${columnComment}" align="center" width="110">
          <template slot-scope="scope">
            <el-image
              v-if="scope.row.${javaField}"
              class="table-image"
              :src="resolveResourceUrl(scope.row.${javaField})"
              :preview-src-list="[resolveResourceUrl(scope.row.${javaField})]" />
            <span v-else>-</span>
          </template>
        </el-table-column>
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
        <el-table-column prop="${javaField}" label="${columnComment}" align="center">
          <template slot-scope="scope">
#if($remoteSelect)
            {{ getOptionLabel(''${javaField}'', scope.row.${javaField}) }}
#else
            {{ _dict.getDictLabel(''${column.dictType}'', scope.row.${javaField}) }}
#end
          </template>
        </el-table-column>
#else
        <el-table-column prop="${javaField}" label="${columnComment}" align="center" />
#end
#end
#end
        #if($showView || $showEdit || $showDelete || $hasRowBusinessActions)
        <el-table-column label="操作" width="230" align="center">
          <template slot-scope="scope">
            #if($showView)
            <el-button type="text" icon="el-icon-view" @click="handleView(scope.row)">查看</el-button>
            #end
            #if($showEdit)
            <el-button type="text" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
            #end
            #if($showDelete)
            <el-popconfirm
              confirm-button-text="确定"
              cancel-button-text="取消"
              icon="el-icon-info"
              icon-color="red"
              title="确定删除这条数据吗？"
              @confirm="del(scope.row.${pkColumn.javaField})">
              <el-button slot="reference" type="text" icon="el-icon-delete" class="danger-text">删除</el-button>
            </el-popconfirm>
            #end
            #foreach($action in $rowBusinessActions)
            #set($buttonLabel = $action.buttonLabel)
            #if(!$buttonLabel)#set($buttonLabel = $action.name)#end
            #set($buttonType = $action.buttonType)
            #if(!$buttonType)#set($buttonType = "text")#end
            #set($buttonIcon = $action.buttonIcon)
            #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end
            <el-button type="${buttonType}" icon="${buttonIcon}" @click="handleBusinessAction(''${action.code}'', scope.row)">${buttonLabel}</el-button>
            #end
          </template>
        </el-table-column>
        #end
      </el-table>
#end

      <el-pagination
        class="pagination"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
        :current-page="queryParams.pageNum"
        :page-sizes="[10, 20, 50, 100]"
        :page-size="queryParams.pageSize"
        layout="total, sizes, prev, pager, next, jumper"
        :total="total" />
#end
    </section>

#if($hasPageDesignBottomBusinessBlocks)
    <section class="embedded-business-blocks bottom-business-blocks">
#foreach($block in $bottomBusinessBlocks)
      <div class="embedded-business-block__item embedded-business-block__item--span-${block.span}">
        <${block.componentName} />
      </div>
#end
    </section>
#end

    #if($renderForm)
    <el-dialog :title="viewing ? ''查看${functionName}'' : (form.${pkColumn.javaField} ? ''编辑${functionName}'' : ''新增${functionName}'')" :visible.sync="dialogFormVisible" width="520px">
      <el-form ref="form" :model="form" label-width="110px" size="small" :disabled="viewing">
#foreach ($column in $formColumns)
#set($renderFormField = false)
#if($designed)
#set($renderFormField = true)
#elseif($column.isEdit == "1")
#set($renderFormField = true)
#end
#if($renderFormField)
#set($javaField=$column.javaField)
#set($columnComment=$column.columnComment)
#set($remoteSelect=$qingRemoteSelectFields.contains($javaField))
#set($autoIncrementField=$column.isIncrement == "1")
        <el-form-item#if($autoIncrementField) v-if="form.${javaField}"#end label="${columnComment}" prop="${javaField}"#if($column.isRequired == "1" && !$autoIncrementField) :rules="[{ required: true, message: ''${columnComment}不能为空'', trigger: #if($column.htmlType == "select")''change''#else''blur''#end }]"#end>
#if($column.htmlType == "input")
          <el-input v-model="form.${javaField}"#if($autoIncrementField) disabled#end placeholder="请输入${columnComment}" />
#elseif($column.htmlType == "textarea")
          <el-input v-model="form.${javaField}" type="textarea" placeholder="请输入${columnComment}" />
#elseif($column.htmlType == "select" || $column.htmlType == "radio")
          <el-select v-model="form.${javaField}" placeholder="请选择${columnComment}" clearable>
#if($remoteSelect)
            <el-option
              v-for="item in getSelectOptions(''${javaField}'')"
              :key="item.value"
              :label="item.label"
              :value="item.value" />
#else
            <el-option
              v-for="item in _dict.getDictDataByType(''${column.dictType}'')"
              :key="item.value"
              :label="item.name"
              :value="item.value" />
#end
          </el-select>
#elseif($column.htmlType == "datetime")
          <el-date-picker v-model="form.${javaField}" type="date" value-format="yyyy-MM-dd" placeholder="选择${columnComment}" />
#elseif($column.htmlType == "imageUpload")
          <el-upload
            class="avatar-uploader"
            action="/api/files/upload"
            :show-file-list="false"
            :on-success="(res) => uploadSuccess(''${javaField}'', res)">
            <img v-if="form.${javaField}" :src="resolveResourceUrl(form.${javaField})" class="avatar" />
            <i v-else class="el-icon-plus avatar-uploader-icon"></i>
          </el-upload>
#elseif($column.htmlType == "fileUpload")
          <el-upload
            action="/api/files/upload"
            :on-success="(res) => uploadSuccess(''${javaField}'', res)">
            <el-button size="small" type="primary">点击上传</el-button>
          </el-upload>
          <div v-if="form.${javaField}" class="file-link">{{ form.${javaField} }}</div>
#end
        </el-form-item>
#end
#end
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogFormVisible = false">取消</el-button>
        <el-button v-if="!viewing" type="primary" @click="save">确定</el-button>
      </div>
    </el-dialog>
    #end

    <el-dialog
      :title="businessActionDialogTitle"
      :visible.sync="businessActionDialogVisible"
      width="520px"
      append-to-body
      :show-close="!businessActionSubmitting"
      :close-on-click-modal="false"
      :close-on-press-escape="!businessActionSubmitting"
      @closed="resetBusinessActionDialog">
      <p v-if="activeBusinessAction && activeBusinessAction.confirmMessage" class="business-action-hint">
        {{ activeBusinessAction.confirmMessage }}
      </p>
      <el-form
        ref="businessActionForm"
        :model="businessActionForm"
        label-width="110px"
        size="small">
        <el-form-item
          v-for="field in businessActionInputFields"
          :key="field.field"
          :label="field.label"
          :prop="field.field"
          :rules="[{ required: true, message: field.label + ''不能为空'', trigger: field.htmlType === ''select'' ? ''change'' : ''blur'' }]">
          <el-date-picker
            v-if="field.htmlType === ''datetime''"
            v-model="businessActionForm[field.field]"
            type="date"
            value-format="yyyy-MM-dd"
            :placeholder="''请选择'' + field.label" />
          <el-select
            v-else-if="field.htmlType === ''select'' && field.dictType"
            v-model="businessActionForm[field.field]"
            clearable
            :placeholder="''请选择'' + field.label">
            <el-option
              v-for="option in getBusinessActionFieldOptions(field)"
              :key="option.value"
              :label="option.label"
              :value="option.value" />
          </el-select>
          <el-input
            v-else-if="field.htmlType === ''textarea''"
            v-model="businessActionForm[field.field]"
            type="textarea"
            :rows="3"
            :placeholder="''请输入'' + field.label" />
          <el-input
            v-else
            v-model="businessActionForm[field.field]"
            :placeholder="''请输入'' + field.label" />
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button
          :disabled="businessActionSubmitting"
          @click="businessActionDialogVisible = false">
          取消
        </el-button>
        <el-button
          type="primary"
          :loading="businessActionSubmitting"
          @click="submitBusinessAction">
          {{ activeBusinessAction && activeBusinessAction.buttonLabel ? activeBusinessAction.buttonLabel : ''确认执行'' }}
        </el-button>
      </div>
    </el-dialog>
  </section>
</template>

<script>
#foreach($block in $embeddedBusinessBlocks)
import ${block.componentName} from "${block.relativeImportPath}"
#end

export default {
  name: "${BusinessName}",
#if($hasPageDesignEmbeddedBusinessBlocks)
  components: {
#foreach($block in $embeddedBusinessBlocks)
    ${block.componentName}#if($foreach.hasNext),#end
#end
  },
#end
  data() {
    return {
      ${businessName}List: [],
      total: 0,
      queryParams: {
        pageNum: 1,
        pageSize: 10,
#if($currentUserOnly)
        __currentUserOnly: true,
#end
#foreach ($column in $queryColumns)
#set($renderQueryParam = false)
#if(!$showQuery)
#set($renderQueryParam = false)
#elseif($designed)
#set($renderQueryParam = true)
#elseif($column.isQuery == "1")
#set($renderQueryParam = true)
#end
#if($renderQueryParam)
        ${column.javaField}: undefined,
#end
#end
      },
#if($isDetailPage)
      detailRecord: {},
      detailLoading: false,
      detailLoaded: false,
#end
      form: {},
      viewing: false,
      dialogFormVisible: false,
      businessActionDialogVisible: false,
      businessActionSubmitting: false,
      activeBusinessAction: null,
      businessActionBasePayload: {},
      businessActionForm: {},
      businessActionInputFields: [],
      multipleSelection: [],
      remoteOptionMap: {},
      remoteSelectConfig: {
#foreach($remote in $qingRemoteSelects)
        ${remote.field}: {
          path: "${remote.path}",
          valueField: "${remote.valueField}",
          labelField: "${remote.labelField}"
        }#if($foreach.hasNext),#end
#end
      },
      activeTab: "最新公告",
      noticeTabs: ["最新公告", "热门赛事", "热门攻略", "热门帖子"],
      notices: [
        { title: "《${functionName}》资源内容更新与支持", time: "2025-06-24 16:50:40" },
        { title: "社区活动与资源维护窗口预告", time: "2025-06-24 16:48:30" },
        { title: "新内容发布和玩家反馈整理", time: "2025-06-24 10:30:24" },
        { title: "版本资料、攻略和周边内容同步", time: "2025-06-23 19:20:12" }
      ],
      businessActionMap: {
#foreach($action in $allPageBusinessActions)
        "${action.code}": {
          name: "${action.name}",
          buttonLabel: "${action.buttonLabel}",
          method: "${action.method}",
          path: "${action.path}",
          successMessage: "${action.successMessage}",
          refreshTarget: "${action.refreshTarget}",
          confirmMessage: "${action.confirmMessage}",
          requestFields: [#foreach($field in $action.requestFields)"${field}"#if($foreach.hasNext), #end#end],
          currentUserFields: [#foreach($field in $action.currentUserFields)"${field}"#if($foreach.hasNext), #end#end],
          inputFields: [
#foreach($field in $action.inputFields)
            {
              field: "${field.field}",
              label: "${field.label}",
              htmlType: "${field.htmlType}",
              dictType: "${field.dictType}"
            }#if($foreach.hasNext),#end
#end
          ]
        }#if($foreach.count != $allPageBusinessActions.size()),#end
#end
      },
      routeActionMap: {
#foreach($routeAction in $pageDesignRouteActionEntries)
        "${routeAction.key}": {
          path: "${routeAction.path}",
          paramMap: {#set($paramMap = $routeAction.paramMap)#foreach($param in $paramMap.entrySet())"${param.key}":"${param.value}"#if($foreach.count != $paramMap.size()),#end#end}
        }#if($foreach.count != $pageDesignRouteActionEntries.size()),#end
#end
      }
    }
  },
  computed: {
    businessActionDialogTitle() {
      if (!this.activeBusinessAction) {
        return "业务操作"
      }
      return this.activeBusinessAction.buttonLabel || this.activeBusinessAction.name || "业务操作"
    }
  },
  created() {
    this.loadRemoteOptions()
#if($isDetailPage)
    this.loadDetail()
#else
    this.load()
#end
  },
  methods: {
    loadRemoteOptions() {
      Object.keys(this.remoteSelectConfig).forEach(field => {
        const config = this.remoteSelectConfig[field]
        this.request.get(config.path, {
          params: { pageNum: 1, pageSize: 999 }
        }).then(res => {
          const data = res.data || {}
          const records = Array.isArray(data) ? data : (data.records || [])
          const options = records.map(item => ({
            value: item[config.valueField],
            label: item[config.labelField]
          }))
          this.#[[$set]]#(this.remoteOptionMap, field, options)
        })
      })
    },
    getSelectOptions(field) {
      return this.remoteOptionMap[field] || []
    },
    getOptionLabel(field, value) {
      const option = this.getSelectOptions(field).find(item => String(item.value) === String(value))
      if (option) {
        return option.label
      }
      return value === undefined || value === null || value === "" ? "-" : value
    },
#if(!$isDetailPage)
    load() {
      this.request.get("/${moduleName}/${businessName}/page", {
        params: this.queryParams
      }).then(res => {
        const page = res.data || {}
        this.${businessName}List = page.records || []
        this.total = page.total || 0
      })
    },
#end
#if($isDetailPage)
    resolveDetailId() {
      const params = this.#[[$route]]# && this.#[[$route]]#.params ? this.#[[$route]]#.params : {}
      return params.id || params.${pkColumn.javaField} || ""
    },
    loadDetail() {
      const detailId = this.resolveDetailId()
      if (!detailId) {
        this.detailRecord = {}
        this.form = {}
        this.detailLoaded = true
        return
      }
      this.detailLoading = true
      this.request.get("/${moduleName}/${businessName}/" + detailId).then(res => {
        this.detailRecord = res.data || {}
        this.form = JSON.parse(JSON.stringify(this.detailRecord))
        this.detailLoaded = true
      }).finally(() => {
        this.detailLoading = false
      })
    },
    formatDetailValue(value) {
      return value === undefined || value === null || value === "" ? "-" : value
    },
#end
    save() {
      this.#[[$refs]]#.form.validate(valid => {
        if (!valid) {
          return
        }
        this.request.post("/${moduleName}/${businessName}", this.form).then(res => {
          if (res.code === "200") {
            this.#[[$message]]#.success("保存成功")
            this.dialogFormVisible = false
            this.load()
          } else {
            this.#[[$message]]#.error(res.msg || "保存失败")
          }
        })
      })
    },
    handleAdd() {
      if (this.handleConfiguredRoute("toolbar:create")) {
        return
      }
      this.form = {}
      this.viewing = false
      this.dialogFormVisible = true
      this.#[[$nextTick]]#(() => this.#[[$refs]]#.form && this.#[[$refs]]#.form.clearValidate())
    },
    handleView(row) {
      if (this.handleConfiguredRoute("table:view", row)) {
        return
      }
      if (this.handleConfiguredRoute("cardActions:view", row)) {
        return
      }
      this.form = JSON.parse(JSON.stringify(row))
      this.viewing = true
      this.dialogFormVisible = true
    },
    handleEdit(row) {
      if (this.handleConfiguredRoute("table:edit", row)) {
        return
      }
      if (this.handleConfiguredRoute("cardActions:edit", row)) {
        return
      }
      this.form = JSON.parse(JSON.stringify(row))
      this.viewing = false
      this.dialogFormVisible = true
      this.#[[$nextTick]]#(() => this.#[[$refs]]#.form && this.#[[$refs]]#.form.clearValidate())
    },
    del(${pkColumn.javaField}) {
      this.request.delete("/${moduleName}/${businessName}/" + ${pkColumn.javaField}).then(res => {
        if (res.code === "200") {
          this.#[[$message]]#.success("删除成功")
          this.load()
        } else {
          this.#[[$message]]#.error(res.msg || "删除失败")
        }
      })
    },
    handleSelectionChange(val) {
      this.multipleSelection = val
    },
    handleCardSelectionChange() {
      this.multipleSelection = this.${businessName}List.filter(item => item.__checked)
    },
    handleConfiguredRoute(actionKey, row) {
      const action = this.routeActionMap[actionKey]
      if (!action || !action.path) {
        return false
      }
      const path = this.buildConfiguredRoutePath(action.path, action.paramMap || {}, row)
      if (!path) {
        return false
      }
      this.#[[$router]]#.push(path)
      return true
    },
    buildConfiguredRoutePath(routePath, paramMap, row) {
      let path = routePath
      Object.keys(paramMap || {}).forEach(param => {
        const field = paramMap[param]
        const value = row && row[field]
        if (value !== undefined && value !== null && value !== "") {
          path = path.replace(new RegExp(":" + param + "(?=/|$)", "g"), encodeURIComponent(value))
        }
      })
      return this.hasUnresolvedRouteParam(path) ? "" : path
    },
    hasUnresolvedRouteParam(path) {
      return String(path || "").split("/").some(segment => segment.indexOf(":") === 0)
    },
    delBatch() {
      const ids = this.multipleSelection.map(item => item.${pkColumn.javaField})
      if (!ids.length) {
        this.#[[$message]]#.warning("请先选择要删除的数据")
        return
      }
      this.request.post("/${moduleName}/${businessName}/del/batch", ids).then(res => {
        if (res.code === "200") {
          this.#[[$message]]#.success("批量删除成功")
          this.load()
        } else {
          this.#[[$message]]#.error(res.msg || "批量删除失败")
        }
      })
    },
    reset() {
      this.queryParams = {
        pageNum: 1,
        pageSize: 10#if($currentUserOnly),
        __currentUserOnly: true#end
      }
      this.load()
    },
    handleBusinessAction(code, row) {
      if (this.handleConfiguredRoute("business:" + code, row) || this.handleConfiguredRoute("toolbar:" + code, row) || this.handleConfiguredRoute("table:" + code, row)) {
        return
      }
      const action = this.businessActionMap[code]
      if (!action || !action.path) {
        this.#[[$message]]#.warning("动作未配置")
        return
      }
      const payload = this.buildBusinessActionPayload(action, row)
      const missingFields = (action.inputFields || []).filter(field => !this.hasBusinessActionValue(payload[field.field]))
      if (missingFields.length > 0) {
        this.openBusinessActionDialog(action, payload, missingFields)
        return
      }
      if (action.confirmMessage) {
        this.#[[$confirm]]#(action.confirmMessage, "提示", {
          confirmButtonText: "确定",
          cancelButtonText: "取消",
          type: "warning"
        }).then(() => this.executeBusinessAction(action, payload)).catch(() => {})
        return
      }
      this.executeBusinessAction(action, payload).catch(() => {})
    },
    openBusinessActionDialog(action, payload, fields) {
      const form = {}
      fields.forEach(field => {
        form[field.field] = this.hasBusinessActionValue(payload[field.field]) ? payload[field.field] : undefined
      })
      this.activeBusinessAction = action
      this.businessActionBasePayload = Object.assign({}, payload)
      this.businessActionInputFields = fields.slice()
      this.businessActionForm = form
      this.businessActionDialogVisible = true
      this.#[[$nextTick]]#(() => {
        const formRef = this.#[[$refs]]#.businessActionForm
        if (formRef) {
          formRef.clearValidate()
        }
      })
    },
    submitBusinessAction() {
      const formRef = this.#[[$refs]]#.businessActionForm
      if (!formRef || !this.activeBusinessAction) {
        return
      }
      formRef.validate(valid => {
        if (!valid) {
          return
        }
        const action = this.activeBusinessAction
        const payload = Object.assign({}, this.businessActionBasePayload, this.businessActionForm)
        this.businessActionSubmitting = true
        this.executeBusinessAction(action, payload)
          .then(() => {
            this.businessActionDialogVisible = false
          })
          .catch(() => {})
          .finally(() => {
            this.businessActionSubmitting = false
          })
      })
    },
    resetBusinessActionDialog() {
      this.activeBusinessAction = null
      this.businessActionBasePayload = {}
      this.businessActionForm = {}
      this.businessActionInputFields = []
    },
    hasBusinessActionValue(value) {
      if (value === undefined || value === null) {
        return false
      }
      return typeof value !== "string" || value.trim() !== ""
    },
    getBusinessActionFieldOptions(field) {
      if (!field || !field.dictType) {
        return []
      }
      return this._dict.getDictDataByType(field.dictType).map(option => ({
        label: option.name,
        value: option.value
      }))
    },
    executeBusinessAction(action, payload) {
      const method = String(action.method || "POST").toLowerCase()
      const request = method === "get"
        ? this.request.get(action.path, { params: payload })
        : this.request[method] ? this.request[method](action.path, payload) : this.request.post(action.path, payload)
      return request.then(res => {
        if (res.code !== "200") {
          return Promise.reject(new Error(res.msg || "操作失败"))
        }
        this.#[[$message]]#.success(action.successMessage || "操作成功")
        this.refreshBusinessActionTarget(action.refreshTarget)
        return res
      }).catch(error => {
        const data = error && error.response ? error.response.data : null
        this.#[[$message]]#.error((data && data.msg) || (error && error.message) || "操作失败")
        return Promise.reject(error)
      })
    },
    refreshBusinessActionTarget(refreshTarget) {
      const target = refreshTarget || "list"
      if (target === "none") {
        return
      }
      if (target === "page") {
        window.location.reload()
        return
      }
#if($isDetailPage)
      this.loadDetail()
#else
      this.load()
#end
    },
    buildBusinessActionPayload(action, row) {
      const payload = {}
      const currentUserFields = new Set(action.currentUserFields || [])
      ;(action.requestFields || []).forEach(field => {
        if (currentUserFields.has(field)) {
          return
        }
        const value = this.resolvePayloadValue(field, row)
        if (value !== undefined) {
          payload[field] = value
        }
      })
      return payload
    },
    resolvePayloadValue(field, row) {
      const camelField = this.toCamelCase(field)
      const sources = [row, this.detailRecord, this.form, this.queryParams]
      for (const source of sources) {
        if (!source) {
          continue
        }
        if (source[field] !== undefined) {
          return source[field]
        }
        if (source[camelField] !== undefined) {
          return source[camelField]
        }
      }
      return undefined
    },
    toCamelCase(value) {
      return String(value || "").replace(/_([a-zA-Z0-9])/g, (_, char) => char.toUpperCase())
    },
    handleSizeChange(pageSize) {
      this.queryParams.pageSize = pageSize
      this.load()
    },
    handleCurrentChange(pageNum) {
      this.queryParams.pageNum = pageNum
      this.load()
    },
    resolveResourceUrl(url) {
      if (!url) {
        return ""
      }
      if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0 || url.indexOf("//") === 0 || url.indexOf("data:") === 0 || url.indexOf("blob:") === 0) {
        return url
      }
      if (url.indexOf("/api/") === 0) {
        return url
      }
      if (url.indexOf("/files/") === 0) {
        return "/api" + url
      }
      return url
    },
    uploadSuccess(field, res) {
      if (res && res.code === "200" && res.data && res.data.url) {
        this.#[[$set]]#(this.form, field, res.data.url)
      } else {
        this.#[[$message]]#.error("上传失败，请重试")
      }
    }
  }
}
</script>

<style scoped>
.portal-page {
  display: grid;
  gap: 22px;
}

.embedded-business-blocks {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: 16px;
}

.embedded-business-block__item--span-12 { grid-column: span 12; }
.embedded-business-block__item--span-6 { grid-column: span 6; }
.embedded-business-block__item--span-4 { grid-column: span 4; }

.content-board {
  display: grid;
  grid-template-columns: minmax(0, 1.36fr) minmax(320px, 0.64fr);
  gap: 24px;
  align-items: stretch;
}

.feature-card {
  position: relative;
  min-height: 270px;
  overflow: hidden;
  background: #ffffff;
}

@media (max-width: 900px) {
  .embedded-business-block__item {
    grid-column: span 12;
  }
}

.feature-illustration {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(135deg, rgba(10, 66, 99, 0.92), rgba(255, 126, 86, 0.9)),
    #184967;
}

.feature-illustration::before,
.feature-illustration::after {
  position: absolute;
  display: block;
  content: "";
}

.feature-illustration::before {
  left: 8%;
  top: 18%;
  width: 48%;
  height: 42%;
  border: 8px solid rgba(255, 255, 255, 0.8);
  border-radius: 6px;
  transform: rotate(-8deg);
}

.feature-illustration::after {
  right: 9%;
  bottom: -12%;
  width: 290px;
  height: 290px;
  border-radius: 50%;
  background:
    radial-gradient(circle at 36% 38%, #ffffff 0 10px, transparent 11px),
    radial-gradient(circle at 64% 38%, #ffffff 0 10px, transparent 11px),
    radial-gradient(circle at 50% 58%, transparent 0 38px, #153d58 39px 45px, transparent 46px),
    #39a6d8;
  box-shadow: 0 20px 0 #133c58;
}

.device-face {
  position: absolute;
  left: 24%;
  bottom: 20%;
  display: grid;
  grid-template-columns: repeat(2, 36px);
  gap: 18px;
  padding: 18px;
  border-radius: 48px;
  background: rgba(21, 62, 88, 0.9);
  transform: rotate(7deg);
}

.device-face span {
  width: 36px;
  height: 36px;
  border: 5px solid #f7ebe0;
  border-radius: 50%;
}

.feature-copy {
  position: absolute;
  left: 28px;
  bottom: 24px;
  max-width: 420px;
  color: #ffffff;
}

.feature-copy span {
  display: inline-block;
  margin-bottom: 8px;
  color: #ffe1b6;
  font-size: 13px;
  font-weight: 700;
}

.feature-copy h2,
.feature-copy p,
.panel-heading h2 {
  margin: 0;
  letter-spacing: 0;
}

.feature-copy h2 {
  font-size: 28px;
  line-height: 1.25;
}

.feature-copy p {
  margin-top: 8px;
  color: rgba(255, 255, 255, 0.88);
  line-height: 1.7;
}

.notice-panel {
  min-width: 0;
  background: #ffffff;
}

.notice-tabs {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
  border-bottom: 1px solid #edf0f5;
}

.notice-tabs button {
  position: relative;
  min-height: 38px;
  border: 0;
  color: #475569;
  background: transparent;
  cursor: pointer;
}

.notice-tabs button.active {
  color: #19a6ad;
  font-weight: 700;
}

.notice-tabs button.active::after {
  position: absolute;
  right: 8px;
  bottom: -1px;
  left: 8px;
  height: 2px;
  background: #19a6ad;
  content: "";
}

.notice-list {
  display: grid;
  gap: 18px;
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
}

.notice-list li {
  display: grid;
  gap: 7px;
}

.notice-list strong {
  color: #172033;
  font-size: 15px;
  font-weight: 500;
  line-height: 1.55;
}

.notice-list span {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: #7b8493;
  font-size: 13px;
}

.data-panel,
.notice-panel {
  padding: 20px;
  border: 1px solid #edf0f5;
  border-radius: 4px;
}

.data-panel {
  background: #ffffff;
}

.split-data-panel {
  display: grid;
  grid-template-columns: 260px minmax(0, 1fr);
  gap: 16px;
}

.split-data-panel .panel-heading {
  grid-column: 1 / -1;
  margin-bottom: 0;
}

.split-data-panel .filter-bar {
  align-content: flex-start;
  align-self: start;
  flex-direction: column;
  margin-bottom: 0;
  padding: 14px;
  border: 1px solid #edf0f5;
  border-radius: 4px;
  background: #f8fafc;
}

.split-data-panel .filter-item,
.split-data-panel .filter-bar .el-button {
  width: 100%;
}

.split-data-panel .resource-table,
.split-data-panel .resource-card-grid,
.split-data-panel .pagination {
  grid-column: 2;
}

.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 18px;
}

.section-kicker {
  color: #19a6ad;
  font-size: 13px;
  font-weight: 700;
}

.panel-heading h2 {
  margin-top: 6px;
  color: #172033;
  font-size: 22px;
}

.panel-actions,
.filter-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.filter-bar {
  margin-bottom: 14px;
}

.filter-item {
  width: 220px;
}

.resource-table {
  width: 100%;
}

.resource-card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 14px;
}

.resource-card-item {
  position: relative;
  display: grid;
  gap: 12px;
  min-height: 180px;
  padding: 16px;
  border: 1px solid #edf0f5;
  border-radius: 4px;
  background: #ffffff;
}

.resource-card-check {
  position: absolute;
  top: 12px;
  right: 12px;
}

.resource-card-body {
  display: grid;
  gap: 10px;
}

.resource-card-field {
  display: grid;
  gap: 4px;
}

.resource-card-title {
  margin: 0;
  color: #172033;
  font-size: 17px;
  line-height: 1.35;
}

.resource-card-field span {
  color: #7b8493;
  font-size: 12px;
}

.resource-card-field strong {
  color: #172033;
  font-size: 14px;
  font-weight: 600;
  line-height: 1.45;
  word-break: break-word;
}

.resource-card-image {
  width: 100%;
  height: 132px;
  border-radius: 4px;
  object-fit: cover;
}

.resource-card-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding-top: 4px;
  border-top: 1px solid #edf0f5;
}

.detail-view {
  min-height: 180px;
}

.detail-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 14px 18px;
  margin: 0;
}

.detail-field {
  display: grid;
  gap: 6px;
  padding: 14px 0;
  border-bottom: 1px solid #edf0f5;
}

.detail-field.wide {
  grid-column: 1 / -1;
}

.detail-field dt {
  color: #7b8493;
  font-size: 13px;
  font-weight: 600;
}

.detail-field dd {
  margin: 0;
  color: #172033;
  font-size: 15px;
  line-height: 1.65;
  word-break: break-word;
}

.detail-image {
  width: 180px;
  height: 128px;
  border-radius: 4px;
  object-fit: cover;
}

.detail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid #edf0f5;
}

.empty-detail {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 120px;
  color: #7b8493;
}

.pagination {
  margin-top: 14px;
  text-align: right;
}

.danger-text {
  color: #f56c6c;
}

.table-image {
  width: 52px;
  height: 52px;
  border-radius: 4px;
}

.avatar-uploader .avatar {
  display: block;
  width: 96px;
  height: 96px;
  object-fit: cover;
}

.avatar-uploader-icon {
  width: 96px;
  height: 96px;
  border: 1px dashed #d9d9d9;
  border-radius: 4px;
  color: #8c939d;
  line-height: 96px;
  text-align: center;
}

.file-link {
  margin-top: 6px;
  color: #606266;
  word-break: break-all;
}

.business-action-hint {
  margin: 0 0 18px;
  padding: 10px 12px;
  border-radius: 4px;
  color: #606266;
  background: #f5f7fa;
  line-height: 1.6;
}

.business-action-hint + .el-form .el-date-editor,
.business-action-hint + .el-form .el-select,
.business-action-hint + .el-form .el-input,
.el-dialog .el-form .el-date-editor,
.el-dialog .el-form .el-select {
  width: 100%;
}

@media (max-width: 980px) {
  .content-board {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 720px) {
  .split-data-panel {
    grid-template-columns: 1fr;
  }

  .split-data-panel .panel-heading,
  .split-data-panel .resource-table,
  .split-data-panel .resource-card-grid,
  .split-data-panel .pagination {
    grid-column: 1;
  }

  .panel-heading {
    align-items: flex-start;
    flex-direction: column;
  }

  .filter-item {
    width: 100%;
  }

  .notice-tabs {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>
', 'admin', sysdate()),
(920312, 9203, 'frontend-login.vue.vm', NULL, 'frontend-login.vue.vm', '#set($portalProjectName = $projectName)
#if(!$portalProjectName || $portalProjectName == "")
#set($portalProjectName = $functionName)
#end
#if(!$portalProjectName || $portalProjectName == "")
#set($portalProjectName = "Portal")
#end
#set($portalDefaultPath = $defaultFrontendPath)
#if(!$portalDefaultPath || $portalDefaultPath == "")
#set($portalDefaultPath = "/${moduleName}/${businessName}")
#end
<template>
  <section class="portal-login">
    <div class="login-visual" aria-hidden="true">
      <div class="login-brand">
        <span class="login-logo"></span>
        <strong>${portalProjectName}</strong>
      </div>
      <h1>${portalProjectName}</h1>
      <p>登录后继续访问你的项目功能和个人数据。</p>
    </div>

    <div class="login-panel">
      <div class="login-heading">
        <span>欢迎回来</span>
        <h2>账号登录</h2>
      </div>

      <el-form :model="form" label-position="top" class="login-form" @submit.native.prevent>
        <el-form-item label="用户名">
          <el-input v-model="form.username" placeholder="请输入用户名" prefix-icon="el-icon-user" />
        </el-form-item>
        <el-form-item label="密码">
          <el-input v-model="form.password" type="password" placeholder="请输入密码" prefix-icon="el-icon-lock" show-password />
        </el-form-item>
        <el-button type="primary" class="login-button" :loading="loading" @click="handleLogin">登录</el-button>
      </el-form>
    </div>
  </section>
</template>

<script>
export default {
  name: "PortalLogin",
  data() {
    return {
      loading: false,
      form: {
        username: "user",
        password: "[REDACTED]
      }
    }
  },
  methods: {
    handleLogin() {
      if (!this.form.username || !this.form.password) {
        this.#[[$message]]#.warning("请输入用户名和密码")
        return
      }

      this.loading = true
      this.request.post("/auth/login", this.form)
        .then(res => {
          if (!res || res.code !== "200" || !res.data || !res.data.token) {
            throw new Error((res && res.msg) || "登录失败")
          }
          localStorage.setItem("portal-token", res.data.token)
          localStorage.setItem("portal-user", JSON.stringify(res.data.user || {
            username: this.form.username,
            nickname: this.form.username
          }))
          window.dispatchEvent(new Event("portal-user-change"))
          this.#[[$message]]#.success("登录成功")
          const redirect = this.#[[$route]]#.query.redirect || "${portalDefaultPath}"
          this.#[[$router]]#.push(redirect)
        })
        .catch(error => {
          this.#[[$message]]#.error(error.message || "登录失败")
        })
        .finally(() => {
          this.loading = false
        })
    }
  }
}
</script>

<style scoped>
.portal-login {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 430px;
  min-height: 100vh;
  background:
    linear-gradient(132deg, rgba(24, 141, 149, 0.95), rgba(44, 120, 215, 0.88)),
    #168d95;
}

.login-visual {
  position: relative;
  display: flex;
  min-width: 0;
  flex-direction: column;
  justify-content: center;
  overflow: hidden;
  padding: 72px 9vw;
  color: #ffffff;
}

.login-visual::before {
  position: absolute;
  inset: 0;
  display: block;
  content: "";
  opacity: 0.58;
  background:
    linear-gradient(42deg, transparent 0 18%, rgba(255, 255, 255, 0.18) 18% 27%, transparent 27% 100%),
    linear-gradient(132deg, transparent 0 48%, rgba(246, 186, 51, 0.32) 48% 55%, transparent 55% 100%);
}

.login-brand,
.login-visual h1,
.login-visual p {
  position: relative;
  z-index: 1;
}

.login-brand {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 46px;
  font-size: 22px;
}

.login-logo {
  width: 28px;
  height: 28px;
  border-radius: 8px 18px 8px 18px;
  background: linear-gradient(135deg, #38d8f0 0 35%, #f3ba28 35% 66%, #5bc269 66%);
  transform: rotate(45deg);
}

.login-visual h1,
.login-visual p {
  margin: 0;
}

.login-visual h1 {
  max-width: 680px;
  font-size: 52px;
  font-weight: 600;
  letter-spacing: 0;
  line-height: 1.18;
}

.login-visual p {
  max-width: 520px;
  margin-top: 20px;
  color: rgba(255, 255, 255, 0.88);
  font-size: 17px;
  line-height: 1.8;
}

.login-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 48px;
  background: #ffffff;
}

.login-heading span {
  color: #168d95;
  font-size: 14px;
  font-weight: 700;
}

.login-heading h2 {
  margin: 8px 0 28px;
  color: #111827;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: 0;
}

.login-form {
  display: grid;
  gap: 2px;
}

.login-button {
  width: 100%;
  height: 42px;
  margin-top: 6px;
  border-color: #168d95;
  background: #168d95;
}

@media (max-width: 880px) {
  .portal-login {
    grid-template-columns: 1fr;
  }

  .login-visual {
    min-height: 280px;
    padding: 42px 26px;
  }

  .login-visual h1 {
    font-size: 36px;
  }

  .login-panel {
    padding: 34px 24px 44px;
  }
}
</style>
', 'admin', sysdate()),
(920313, 9203, 'frontend-dict.js.vm', NULL, 'frontend-dict.js.vm', 'const emptyDict = []

export default {
  getDictDataByType() {
    return emptyDict
  },
  getDictLabel(type, value) {
    return value === undefined || value === null || value === "" ? "-" : value
  }
}
', 'admin', sysdate());

-- Backend project structure
INSERT INTO sys_project_structure (node_id, parent_id, node_name, node_type, module, template_id, table_id, category, sort_order, status, create_by, create_time, update_by, update_time) VALUES
(920100, 0, '{projectName}-backend', 'folder', NULL, 9201, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920101, 920100, 'pom.xml', 'file', NULL, 9201, -1, 'pom.xml.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920102, 920100, 'README.md', 'file', NULL, 9201, -1, 'README.md.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920103, 920100, 'sql', 'folder', NULL, 9201, NULL, NULL, 3, '0', 'admin', sysdate(), '', NULL),
(920104, 920103, '{businessName}.sql', 'file', NULL, 9201, 0, 'sql.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920110, 920100, 'src', 'folder', NULL, 9201, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920111, 920110, 'main', 'folder', NULL, 9201, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920112, 920111, 'java', 'folder', NULL, 9201, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920113, 920111, 'resources', 'folder', NULL, 9201, NULL, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920114, 920112, '{packagePath}', 'folder', NULL, 9201, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920115, 920114, '{projectClassName}Application.java', 'file', NULL, 9201, -1, 'Application.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920116, 920114, 'common', 'folder', NULL, 9201, NULL, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920117, 920116, 'Result.java', 'file', NULL, 9201, -1, 'Result.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920118, 920114, 'config', 'folder', NULL, 9201, NULL, NULL, 3, '0', 'admin', sysdate(), '', NULL),
(920119, 920118, 'MybatisPlusConfig.java', 'file', NULL, 9201, -1, 'MybatisPlusConfig.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920120, 920118, 'CorsConfig.java', 'file', NULL, 9201, -1, 'CorsConfig.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920121, 920114, 'controller', 'folder', NULL, 9201, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920122, 920121, 'FileController.java', 'file', NULL, 9201, -1, 'FileController.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920123, 920121, '{ClassName}Controller.java', 'file', NULL, 9201, 0, 'controller.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920124, 920114, 'entity', 'folder', NULL, 9201, NULL, NULL, 5, '0', 'admin', sysdate(), '', NULL),
(920125, 920124, '{ClassName}.java', 'file', NULL, 9201, 0, 'entity.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920126, 920114, 'mapper', 'folder', NULL, 9201, NULL, NULL, 6, '0', 'admin', sysdate(), '', NULL),
(920127, 920126, '{ClassName}Mapper.java', 'file', NULL, 9201, 0, 'mapper.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920128, 920114, 'service', 'folder', NULL, 9201, NULL, NULL, 7, '0', 'admin', sysdate(), '', NULL),
(920129, 920128, 'I{ClassName}Service.java', 'file', NULL, 9201, 0, 'service.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920130, 920128, 'impl', 'folder', NULL, 9201, NULL, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920131, 920130, '{ClassName}ServiceImpl.java', 'file', NULL, 9201, 0, 'serviceImpl.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920132, 920113, 'application.yml', 'file', NULL, 9201, -1, 'application.yml.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920133, 920113, 'mapper', 'folder', NULL, 9201, NULL, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920134, 920133, '{moduleName}', 'folder', NULL, 9201, 0, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920135, 920134, '{ClassName}Mapper.xml', 'file', NULL, 9201, 0, 'mapper.xml.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920136, 920124, 'PortalUser.java', 'file', NULL, 9201, -1, 'PortalUser.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920137, 920126, 'PortalUserMapper.java', 'file', NULL, 9201, -1, 'PortalUserMapper.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920138, 920114, 'security', 'folder', NULL, 9201, NULL, NULL, 8, '0', 'admin', sysdate(), '', NULL),
(920139, 920138, 'PortalAuthTokenStore.java', 'file', NULL, 9201, -1, 'PortalAuthTokenStore.java.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920140, 920138, 'PortalAuthInterceptor.java', 'file', NULL, 9201, -1, 'PortalAuthInterceptor.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920141, 920121, 'PortalAuthController.java', 'file', NULL, 9201, -1, 'PortalAuthController.java.vm', 3, '0', 'admin', sysdate(), '', NULL),
(920142, 920124, 'AdminUser.java', 'file', NULL, 9201, -1, 'AdminUser.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920143, 920126, 'AdminUserMapper.java', 'file', NULL, 9201, -1, 'AdminUserMapper.java.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920144, 920121, 'AdminAuthController.java', 'file', NULL, 9201, -1, 'AdminAuthController.java.vm', 4, '0', 'admin', sysdate(), '', NULL);

-- Admin frontend project structure
INSERT INTO sys_project_structure (node_id, parent_id, node_name, node_type, module, template_id, table_id, category, sort_order, status, create_by, create_time, update_by, update_time) VALUES
(920200, 0, '{projectName}-admin', 'folder', NULL, 9202, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920201, 920200, 'package.json', 'file', NULL, 9202, -1, 'package.json.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920202, 920200, 'babel.config.js', 'file', NULL, 9202, -1, 'babel.config.js.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920203, 920200, 'vue.config.js', 'file', NULL, 9202, -1, 'vue.config.js.vm', 3, '0', 'admin', sysdate(), '', NULL),
(920204, 920200, 'public', 'folder', NULL, 9202, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920205, 920204, 'index.html', 'file', NULL, 9202, -1, 'index.html.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920206, 920200, 'src', 'folder', NULL, 9202, NULL, NULL, 5, '0', 'admin', sysdate(), '', NULL),
(920207, 920206, 'main.js', 'file', NULL, 9202, -1, 'admin-main.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920208, 920206, 'App.vue', 'file', NULL, 9202, -1, 'admin-App.vue.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920209, 920206, 'router', 'folder', NULL, 9202, NULL, NULL, 3, '0', 'admin', sysdate(), '', NULL),
(920210, 920209, 'index.js', 'file', NULL, 9202, -1, 'admin-router.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920211, 920206, 'utils', 'folder', NULL, 9202, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920212, 920211, 'request.js', 'file', NULL, 9202, -1, 'request.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920213, 920211, 'dict.js', 'file', NULL, 9202, -1, 'dict.js.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920214, 920206, 'views', 'folder', NULL, 9202, NULL, NULL, 5, '0', 'admin', sysdate(), '', NULL),
(920218, 920214, 'login', 'folder', NULL, 9202, -1, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920219, 920218, 'index.vue', 'file', NULL, 9202, -1, 'admin-login.vue.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920215, 920214, '{moduleName}', 'folder', NULL, 9202, 0, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920216, 920215, '{businessName}', 'folder', NULL, 9202, 0, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920217, 920216, 'index.vue', 'file', NULL, 9202, 0, 'admin-index.vue.vm', 1, '0', 'admin', sysdate(), '', NULL);

-- Portal frontend project structure
INSERT INTO sys_project_structure (node_id, parent_id, node_name, node_type, module, template_id, table_id, category, sort_order, status, create_by, create_time, update_by, update_time) VALUES
(920300, 0, '{projectName}-web', 'folder', NULL, 9203, NULL, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920301, 920300, 'package.json', 'file', NULL, 9203, -1, 'package.json.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920302, 920300, 'babel.config.js', 'file', NULL, 9203, -1, 'babel.config.js.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920303, 920300, 'vue.config.js', 'file', NULL, 9203, -1, 'vue.config.js.vm', 3, '0', 'admin', sysdate(), '', NULL),
(920304, 920300, 'public', 'folder', NULL, 9203, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920305, 920304, 'index.html', 'file', NULL, 9203, -1, 'index.html.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920306, 920300, 'src', 'folder', NULL, 9203, NULL, NULL, 5, '0', 'admin', sysdate(), '', NULL),
(920307, 920306, 'main.js', 'file', NULL, 9203, -1, 'frontend-main.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920308, 920306, 'App.vue', 'file', NULL, 9203, -1, 'frontend-App.vue.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920309, 920306, 'style.css', 'file', NULL, 9203, -1, 'style.css.vm', 3, '0', 'admin', sysdate(), '', NULL),
(920310, 920306, 'router', 'folder', NULL, 9203, NULL, NULL, 4, '0', 'admin', sysdate(), '', NULL),
(920311, 920310, 'index.js', 'file', NULL, 9203, -1, 'frontend-router.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920312, 920306, 'api', 'folder', NULL, 9203, NULL, NULL, 5, '0', 'admin', sysdate(), '', NULL),
(920313, 920312, 'request.js', 'file', NULL, 9203, -1, 'frontend-request.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920314, 920312, '{businessName}.js', 'file', NULL, 9203, 0, 'frontend-api.js.vm', 2, '0', 'admin', sysdate(), '', NULL),
(920321, 920306, 'utils', 'folder', NULL, 9203, NULL, NULL, 6, '0', 'admin', sysdate(), '', NULL),
(920322, 920321, 'dict.js', 'file', NULL, 9203, -1, 'frontend-dict.js.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920315, 920306, 'views', 'folder', NULL, 9203, NULL, NULL, 7, '0', 'admin', sysdate(), '', NULL),
(920316, 920315, '{moduleName}', 'folder', NULL, 9203, 0, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920317, 920316, '{businessName}', 'folder', NULL, 9203, 0, NULL, 1, '0', 'admin', sysdate(), '', NULL),
(920318, 920317, 'index.vue', 'file', NULL, 9203, 0, 'frontend-index.vue.vm', 1, '0', 'admin', sysdate(), '', NULL),
(920319, 920315, 'login', 'folder', NULL, 9203, NULL, NULL, 2, '0', 'admin', sysdate(), '', NULL),
(920320, 920319, 'index.vue', 'file', NULL, 9203, -1, 'frontend-login.vue.vm', 1, '0', 'admin', sysdate(), '', NULL);
SET FOREIGN_KEY_CHECKS = 1;
