Compare commits

...

15 Commits

60 changed files with 1926 additions and 106 deletions

View File

@@ -17,7 +17,7 @@ class Admin extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip'];
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'agent_id'];
protected array|string $quickSearchField = ['username', 'nickname'];
@@ -25,6 +25,8 @@ class Admin extends Backend
protected string $dataLimitField = 'id';
protected array $withJoinTable = ['channel'];
protected function initController(Request $request): ?Response
{
$this->model = new AdminModel();
@@ -44,7 +46,8 @@ class Admin extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->withJoin($this->withJoinTable, $this->withJoinType ?? 'LEFT')
->visible(['channel' => ['name']])
->alias($alias)
->where($where)
->order($order)
@@ -78,9 +81,13 @@ class Admin extends Backend
'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile',
'group_arr' => 'required|array',
];
if ($this->auth->isSuperAdmin()) {
$rules['channel_id'] = 'required|integer|min:1';
}
$messages = [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password'),
'channel_id.required' => __('Please select channel'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
@@ -88,6 +95,14 @@ class Admin extends Backend
}
}
if (!$this->auth->isSuperAdmin()) {
$currentChannelId = (int) ($this->auth->model->channel_id ?? 0);
if ($currentChannelId <= 0) {
return $this->error(__('Current admin has no channel bound'));
}
$data['channel_id'] = $currentChannelId;
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$result = false;
@@ -98,6 +113,10 @@ class Admin extends Backend
$this->model->startTrans();
try {
$result = $this->model->save($data);
if ($result !== false) {
$agentId = strtolower(md5($this->model->username . $this->model->id));
$this->model->where('id', $this->model->id)->update(['agent_id' => $agentId]);
}
if (!empty($data['group_arr'])) {
$groupAccess = [];
foreach ($data['group_arr'] as $datum) {
@@ -263,11 +282,28 @@ class Admin extends Backend
}
/**
* 远程下拉(Admin 无自定义,走父类默认列表
* 远程下拉(返回管理员列表供 remoteSelect 使用
*/
public function select(Request $request): Response
{
return parent::select($request);
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
private function checkGroupAuth(array $groups): ?Response

View File

@@ -0,0 +1,110 @@
<?php
namespace app\admin\controller\channel;
use Throwable;
use app\common\controller\Backend;
/**
* 渠道管理
*/
class Manage extends Backend
{
/**
* ChannelManage模型对象
* @var object|null
* @phpstan-var \app\common\model\ChannelManage|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'secret'];
protected array $withJoinTable = ['admin'];
protected string|array $quickSearchField = ['id'];
protected bool $autoFillAdminId = true;
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\ChannelManage();
}
/**
* 查看
* @throws Throwable
*/
public function index(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->visible(['admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 白名单(页面按钮规则,用于菜单规则中配置按钮权限)
* 实际编辑通过 edit 接口提交 ip_white 字段
*/
public function whitelist(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->success();
}
/**
* 渠道下拉选择(供 remoteSelect 使用)
*/
public function select(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field('id,name,title')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* add、edit、del、sortable 已由父类 Backend 实现,无需重写即可直接使用
* 若需重写,请确保调用 initializeBackend($request) 并传入 Request 参数
* 若模型有 admin_id 字段需自动填充,可设置 protected bool $autoFillAdminId = true
*/
}

View File

@@ -143,6 +143,10 @@ class Crud extends Backend
$this->langTsData['zh-cn']['quick Search Fields'] = implode('、', $quickSearchFieldZhCnTitle);
$this->controllerData['attr']['quickSearchField'] = $quickSearchField;
if (array_key_exists('admin_id', $fieldsMap)) {
$this->controllerData['attr']['autoFillAdminId'] = true;
}
$weighKey = array_search('weigh', $fieldsMap);
if ($weighKey !== false) {
$this->indexVueData['enableDragSort'] = true;

View File

@@ -1,19 +1,19 @@
<?php
namespace app\admin\controller\mall\pints;
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
/**
* 积分订单
* 收获地址管理
*/
class Order extends Backend
class Address extends Backend
{
/**
* MallPintsOrder模型对象
* MallAddress模型对象
* @var object|null
* @phpstan-var \app\common\model\MallPintsOrder|null
* @phpstan-var \app\common\model\MallAddress|null
*/
protected ?object $model = null;
@@ -26,7 +26,7 @@ class Order extends Backend
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPintsOrder();
$this->model = new \app\common\model\MallAddress();
}
/**
@@ -52,7 +52,9 @@ class Order extends Backend
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with(['mallUser' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username']])
->alias($alias)
->where($where)

View File

@@ -2,8 +2,9 @@
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
/**
* 商品管理
@@ -21,7 +22,10 @@ class Item extends Backend
protected array $withJoinTable = ['admin'];
protected string|array $quickSearchField = ['id'];
protected string|array $quickSearchField = ['id', 'title'];
/** 添加时自动填充 admin_id */
protected bool $autoFillAdminId = true;
public function initialize(): void
{
@@ -31,9 +35,8 @@ class Item extends Backend
/**
* 查看
* @throws Throwable
*/
public function index(\Webman\Http\Request $request): \support\Response
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
@@ -41,15 +44,9 @@ class Item extends Backend
}
if ($request->get('select') || $request->post('select')) {
$this->_select();
return $this->success();
return $this->select($request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
@@ -67,6 +64,27 @@ class Item extends Backend
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
* 远程下拉选择数据(供 remoteSelect 使用)
*/
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field('id,title')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
/**
* 积分订单
*/
class PintsOrder extends Backend
{
/**
* MallPintsOrder模型对象
* @var object|null
* @phpstan-var \app\common\model\MallPintsOrder|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallUser'];
protected string|array $quickSearchField = ['id'];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPintsOrder();
}
/**
* 查看
* @throws Throwable
*/
public function index(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
$this->_select();
return $this->success();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->with(['mallUser' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -1,6 +1,6 @@
<?php
namespace app\admin\controller\mall\redemption;
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
@@ -8,7 +8,7 @@ use app\common\controller\Backend;
/**
* 兑换订单
*/
class Order extends Backend
class RedemptionOrder extends Backend
{
/**
* MallRedemptionOrder模型对象
@@ -52,7 +52,9 @@ class Order extends Backend
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with(['mallUser' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username'], 'mallItem' => ['title']])
->alias($alias)
->where($where)

View File

@@ -45,8 +45,7 @@ class User extends Backend
}
if ($request->get('select') || $request->post('select')) {
$this->_select();
return $this->success();
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
@@ -93,6 +92,9 @@ class User extends Backend
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
//保存管理员admin_id
$data['admin_id'] = $this->auth->id;
$result = false;
$this->model->startTrans();
try {
@@ -182,6 +184,32 @@ class User extends Backend
return $this->success('', ['row' => $row]);
}
/**
* 远程下拉数据(供 remoteSelect 使用)
*/
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password')
->withJoin($this->withJoinTable, $this->withJoinType)
->visible(['admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 删除
*/

View File

@@ -2,4 +2,6 @@
return [
'Group Name Arr' => 'Administrator Grouping ',
'Please use another administrator account to disable the current account!' => 'Disable the current account, please use another administrator account!',
'Please select channel' => 'Please select channel',
'Current admin has no channel bound' => 'Current admin has no channel bound',
];

View File

@@ -3,4 +3,6 @@ return [
'Group Name Arr' => '管理员分组',
'Please use another administrator account to disable the current account!' => '请使用另外的管理员账户禁用当前账户!',
'You have no permission to add an administrator to this group!' => '您没有权限向此分组添加管理员!',
'Please select channel' => '请选择渠道',
'Current admin has no channel bound' => '当前管理员未绑定渠道',
];

View File

@@ -33,7 +33,7 @@ class Auth extends \ba\Auth
protected string $refreshToken = '';
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time'];
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time', 'channel_id'];
public function __construct(array $config = [])
{

View File

@@ -101,6 +101,7 @@ class Helper
'withJoinTable' => 'array',
'defaultSortField' => 'string|array',
'weighField' => 'string',
'autoFillAdminId' => 'bool',
],
];
@@ -692,7 +693,14 @@ class Helper
$modelMethodList = isset($modelData['relationMethodList']) ? array_merge($modelData['methods'], $modelData['relationMethodList']) : $modelData['methods'];
$modelData['methods'] = $modelMethodList ? "\n" . implode("\n", $modelMethodList) : '';
$modelData['append'] = self::buildModelAppend($modelData['append'] ?? []);
$modelData['fieldType'] = self::buildModelFieldType($modelData['fieldType'] ?? []);
$fieldType = $modelData['fieldType'] ?? [];
if ($modelData['autoWriteTimestamp'] == 'true') {
$fieldType = array_merge(
['create_time' => 'integer', 'update_time' => 'integer'],
$fieldType
);
}
$modelData['fieldType'] = self::buildModelFieldType($fieldType);
if (isset($modelData['beforeInsertMixins']['snowflake'])) {
$modelData['beforeInsert'] = self::assembleStub('mixins/model/beforeInsert', [
@@ -726,6 +734,9 @@ class Helper
$attrType = self::$attrType['controller'][$key] ?? '';
if (is_array($item)) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = ['" . implode("', '", $item) . "'];\n";
} elseif ($attrType === 'bool') {
$val = ($item === true || $item === 'true') ? 'true' : 'false';
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = $val;\n";
} elseif ($item) {
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = '$item';\n";
}

View File

@@ -19,6 +19,8 @@ class {%className%} extends Backend
{%methods%}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
* add、edit、del、sortable 已由父类 Backend 实现,无需重写即可直接使用
* 若需重写,请确保调用 initializeBackend($request) 并传入 Request 参数
* 若模型有 admin_id 字段需自动填充,可设置 protected bool $autoFillAdminId = true
*/
}

View File

@@ -97,6 +97,9 @@ trait Backend
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
if ($this->autoFillAdminId && $this->dataLimitField === 'admin_id') {
$data['admin_id'] = $this->auth->id;
}
$result = false;
$this->model->startTrans();

View File

@@ -21,6 +21,8 @@ use support\think\Db;
* @property string $password 密码密文
* @property string $salt 密码盐
* @property string $status 状态:enable=启用,disable=禁用
* @property string $agent_id 代理ID关联渠道
* @property int $channel_id 渠道ID
*/
class Admin extends Model
{
@@ -64,4 +66,12 @@ class Admin extends Model
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
/**
* 关联渠道
*/
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\ChannelManage::class, 'channel_id', 'id');
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace app\api\controller\v1;
use app\common\controller\Api;
use app\common\library\AgentJwt;
use app\common\model\ChannelManage;
use app\admin\model\Admin;
use Webman\Http\Request;
use support\Response;
/**
* API v1 鉴权接口
*/
class Auth extends Api
{
/**
* Agent Token 类型
*/
public const TOKEN_TYPE = 'agent';
/**
* 时间戳有效范围(秒),防止重放攻击
*/
protected int $timeTolerance = 300;
/**
* 获取鉴权 Token
* 参数signature签名、secret密钥、agent_id代理、time时间戳
* 返回authtoken失败返回 code=0 及失败信息
*/
public function authToken(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$signature = $request->post('signature', $request->get('signature', ''));
$secret = $request->post('secret', $request->get('secret', ''));
$agentId = $request->post('agent_id', $request->get('agent_id', ''));
$time = $request->post('time', $request->get('time', ''));
if ($signature === '' || $secret === '' || $agentId === '' || $time === '') {
return $this->error(__('Parameter %s can not be empty', ['signature/secret/agent_id/time']));
}
$timestamp = (int) $time;
if ($timestamp <= 0) {
return $this->error(__('Invalid timestamp'));
}
$now = time();
if ($timestamp < $now - $this->timeTolerance || $timestamp > $now + $this->timeTolerance) {
return $this->error(__('Timestamp expired'));
}
$admin = Admin::where('agent_id', $agentId)->find();
if (!$admin) {
return $this->error(__('Agent not found'));
}
$channelId = (int) ($admin->channel_id ?? 0);
if ($channelId <= 0) {
return $this->error(__('Agent not found'));
}
$channel = ChannelManage::where('id', $channelId)->find();
if (!$channel || $channel->secret === '') {
return $this->error(__('Agent not found'));
}
if ($channel->secret !== $secret) {
return $this->error(__('Invalid agent or secret'));
}
$expectedSignature = hash_hmac('sha256', $agentId . $time, $channel->secret);
if (!hash_equals($expectedSignature, $signature)) {
return $this->error(__('Invalid signature'));
}
$expire = (int) config('buildadmin.agent_auth.token_expire', 86400);
$payload = [
'agent_id' => $agentId,
'channel_id' => $channel->id,
'admin_id' => $admin->id,
];
$authtoken = AgentJwt::encode($payload, $expire);
return $this->success('', [
'authtoken' => $authtoken,
]);
}
}

View File

@@ -12,6 +12,12 @@ return [
'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!',
'Parameter %s can not be empty' => 'Parameter %s can not be empty',
'Invalid timestamp' => 'Invalid timestamp',
'Timestamp expired' => 'Timestamp expired',
'Invalid agent or secret' => 'Invalid agent or secret',
'Invalid signature' => 'Invalid signature',
'Agent not found' => 'Agent not found',
// Member center account
'Data updated successfully~' => 'Data updated successfully~',
'Password has been changed~' => 'Password has been changed~',

View File

@@ -42,6 +42,12 @@ return [
'Please login first' => '请先登录!',
'You have no permission' => '没有权限操作!',
'Parameter error' => '参数错误!',
'Parameter %s can not be empty' => '参数%s不能为空',
'Invalid timestamp' => '无效的时间戳',
'Timestamp expired' => '时间戳已过期',
'Invalid agent or secret' => '代理或密钥无效',
'Invalid signature' => '签名无效',
'Agent not found' => '代理不存在',
'Token expiration' => '登录态过期,请重新登录!',
'Captcha error' => '验证码错误!',
// 会员中心 account

View File

@@ -80,6 +80,11 @@ class Backend extends Api
*/
protected bool $dataLimitFieldAutoFill = true;
/**
* 添加时自动填充 admin_id当模型有 admin_id 字段但无数据权限限制时使用)
*/
protected bool $autoFillAdminId = false;
/**
* 查看请求返回的主表字段
*/
@@ -357,8 +362,10 @@ class Backend extends Api
$order = $order ?: $this->defaultSortField;
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
$orderParts = explode(',', $order);
$orderField = trim($orderParts[0] ?? '');
$orderDir = trim($orderParts[1] ?? 'asc');
$order = $orderField !== '' ? [$orderField => $orderDir ?: 'asc'] : [];
}
if (!$this->orderGuarantee) {
$this->orderGuarantee = [$pk => 'desc'];

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
/**
* Agent 鉴权 JWT 工具
*/
class AgentJwt
{
public const ALG = 'HS256';
/**
* 生成 JWT authtoken
* @param array $payload agent_id, channel_id, admin_id 等
* @param int $expire 有效期(秒)
*/
public static function encode(array $payload, int $expire = 86400): string
{
$now = time();
$payload['iat'] = $now;
$payload['exp'] = $now + $expire;
$secret = self::getSecret();
return JWT::encode($payload, $secret, self::ALG);
}
/**
* 解析并验证 JWT返回 payload
* @return array payload失败返回空数组
*/
public static function decode(string $token): array
{
if ($token === '') {
return [];
}
try {
$secret = self::getSecret();
$decoded = JWT::decode($token, new Key($secret, self::ALG));
return (array) $decoded;
} catch (ExpiredException|SignatureInvalidException|\Throwable) {
return [];
}
}
/**
* 验证 JWT 是否有效
*/
public static function verify(string $token): bool
{
return !empty(self::decode($token));
}
private static function getSecret(): string
{
$secret = config('buildadmin.agent_auth.jwt_secret', '');
if ($secret === '') {
$secret = config('buildadmin.token.key', '');
}
return $secret;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace app\common\library\token\driver;
use app\common\library\token\Driver;
use support\Redis as RedisConnection;
/**
* Token Redis 驱动(提升鉴权接口等高频调用的性能)
* @see Driver
*/
class Redis extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = array_merge([
'prefix' => 'tk:',
'expire' => 2592000,
], $options);
$this->handler = RedisConnection::connection('default');
}
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if ($expire === null) {
$expire = $this->options['expire'] ?? 2592000;
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$key = $this->getKey($token);
$data = [
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
];
$ttl = $expire !== 0 ? $expire : 365 * 86400;
$this->handler->setEx($key, $ttl, json_encode($data));
return true;
}
public function get(string $token): array
{
$key = $this->getKey($token);
$raw = $this->handler->get($key);
if ($raw === false || $raw === null) {
return [];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return [];
}
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0);
return $data;
}
public function check(string $token, string $type, int $userId): bool
{
$data = $this->get($token);
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) {
return false;
}
return $data['type'] === $type && (int) $data['user_id'] === $userId;
}
public function delete(string $token): bool
{
$this->handler->del($this->getKey($token));
return true;
}
public function clear(string $type, int $userId): bool
{
$pattern = $this->options['prefix'] . '*';
$keys = $this->handler->keys($pattern);
foreach ($keys as $key) {
$raw = $this->handler->get($key);
if ($raw !== false && $raw !== null) {
$data = json_decode($raw, true);
if (is_array($data) && ($data['type'] ?? '') === $type && (int) ($data['user_id'] ?? 0) === $userId) {
$this->handler->del($key);
}
}
}
return true;
}
private function getKey(string $token): string
{
return $this->options['prefix'] . $this->getEncryptedToken($token);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* ChannelManage
*/
class ChannelManage extends Model
{
// 表名
protected $name = 'channel_manage';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'ip_white' => 'json',
];
/**
* 获取 IP 白名单,统一返回字符串数组格式
* 兼容:["127.0.0.1"]、[{"value":"127.0.0.1"}]、[{"127.0.0.1":""}]
*/
public function getipWhiteAttr($value): array
{
$arr = is_array($value) ? $value : (!$value ? [] : json_decode($value, true));
if (!is_array($arr)) {
return [];
}
$result = [];
foreach ($arr as $item) {
if (is_string($item)) {
$result[] = $item;
} elseif (is_array($item)) {
if (isset($item['value'])) {
$result[] = $item['value'];
} else {
$key = array_key_first($item);
if ($key !== null && $key !== '') {
$result[] = $key;
}
}
}
}
return array_values(array_filter($result));
}
/**
* 写入 IP 白名单,存储格式 ["127.0.0.1","192.168.1.1"]
*/
public function setipWhiteAttr($value): array
{
$arr = is_array($value) ? $value : [];
$result = [];
foreach ($arr as $ip) {
$ip = is_string($ip) ? trim($ip) : '';
if ($ip !== '') {
$result[] = $ip;
}
}
return array_values($result);
}
/**
* 创建时自动生成密钥strtoupper(md5(name+id))
*/
protected static function onAfterInsert($model): void
{
$pk = $model->getPk();
$secret = strtoupper(md5($model->name . $model->$pk));
$model->where($pk, $model->$pk)->update(['secret' => $secret]);
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* MallAddress
*/
class MallAddress extends Model
{
use TimestampInteger;
// 表名
protected $name = 'mall_address';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 追加属性
protected $append = [
'region_text',
];
public function getregionAttr($value): array
{
if ($value === '' || $value === null) return [];
if (!is_array($value)) {
return explode(',', $value);
}
return $value;
}
public function setregionAttr($value): string
{
return is_array($value) ? implode(',', $value) : $value;
}
public function getregionTextAttr($value, $row): string
{
if ($row['region'] === '' || $row['region'] === null) return '';
$cityNames = \support\think\Db::name('area')->whereIn('id', $row['region'])->column('name');
return $cityNames ? implode(',', $cityNames) : '';
}
public function mallUser(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
}
}

View File

@@ -2,6 +2,7 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
@@ -9,12 +10,11 @@ use support\think\Model;
*/
class MallItem extends Model
{
// 表名
use TimestampInteger;
protected $name = 'mall_item';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
protected bool $autoWriteTimestamp = true;
public function admin(): \think\model\relation\BelongsTo
{

View File

@@ -2,6 +2,7 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
@@ -9,6 +10,8 @@ use support\think\Model;
*/
class MallPintsOrder extends Model
{
use TimestampInteger;
// 表名
protected $name = 'mall_pints_order';

View File

@@ -2,6 +2,7 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
@@ -9,6 +10,8 @@ use support\think\Model;
*/
class MallRedemptionOrder extends Model
{
use TimestampInteger;
// 表名
protected $name = 'mall_redemption_order';

View File

@@ -18,7 +18,7 @@ class User extends Model
public function getAvatarAttr($value): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string

View File

@@ -0,0 +1,31 @@
<?php
namespace app\common\validate;
use think\Validate;
class ChannelManage extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -0,0 +1,31 @@
<?php
namespace app\common\validate;
use think\Validate;
class MallAddress extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -165,6 +165,18 @@ if (!function_exists('get_auth_token')) {
}
}
if (!function_exists('get_agent_jwt_payload')) {
/**
* 解析 Agent JWT authtoken返回 payloadagent_id、channel_id、admin_id 等)
* @param string $token authtoken
* @return array 成功返回 payload失败返回空数组
*/
function get_agent_jwt_payload(string $token): array
{
return \app\common\library\AgentJwt::decode($token);
}
}
if (!function_exists('get_controller_path')) {
/**
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath

View File

@@ -39,7 +39,8 @@
"robmorgan/phinx": "^0.15",
"nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0"
"topthink/think-validate": "^3.0",
"firebase/php-jwt": "^7.0"
},
"suggest": {
"ext-event": "For better performance. "

View File

@@ -39,9 +39,9 @@ return [
],
// 代理服务器IPRequest 类将尝试获取这些代理服务器发送过来的真实IP
'proxy_server_ip' => [],
// Token 配置
// Token 配置(鉴权接口 authtoken 等高频调用建议使用 redis 提升性能)
'token' => [
// 默认驱动方式
// 默认驱动mysql | redisredis 需确保 config/redis.php 已配置且 phpredis 扩展可用)
'default' => 'mysql',
// 加密key
'key' => 'L1iYVS0PChKA9pjcFdmOGb4zfDIHo5xw',
@@ -81,6 +81,17 @@ return [
'cdn_url' => '',
// 内容分发网络URL参数将自动添加 `?`,之后拼接到 cdn_url 的结尾(例如 `imageMogr2/format/heif`
'cdn_url_params' => '',
// 代理鉴权配置(/api/v1/authToken
'agent_auth' => [
// agent_id => secret 映射
'agents' => [
// 'agent_001' => 'your_secret_key',
],
// JWT 签名密钥(留空则使用 token.key
'jwt_secret' => '',
// Token 有效期(秒),默认 24 小时
'token_expire' => 86400,
],
// 版本号
'version' => 'v2.3.6',
// 中心接口地址(用于请求模块市场的数据等用途)

View File

@@ -108,6 +108,9 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
// api/ems
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
// api/v1 鉴权
Route::add(['GET', 'POST'], '/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']);
// ==================== Admin 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
@@ -245,11 +248,11 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action 或 module.sub.Controller/action ====================
// 前端使用 /admin/user.Rule/index、/admin/mall.pints.Order/index 等格式,需转换为控制器调用
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action}',
'/admin/{controllerPart:[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)+}/{action}',
function (\Webman\Http\Request $request, string $controllerPart, string $action) {
$pos = strpos($controllerPart, '.');
if ($pos === false) {
@@ -257,7 +260,9 @@ Route::add(
}
$module = substr($controllerPart, 0, $pos);
$controller = substr($controllerPart, $pos + 1);
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controller;
// 支持多级路径pints.Order -> pints\Orderredemption.Order -> redemption\Order
$controllerClass = str_replace('.', '\\', $controller);
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controllerClass;
if (!class_exists($class)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}

View File

@@ -232,14 +232,16 @@ const getData = debounce((initValue: valueTypes = '') => {
state.params.initValue = initValue
getSelectData(props.remoteUrl, state.keyword, state.params)
.then((res) => {
let opts = res.data.options ? res.data.options : res.data.list
const data = res?.data ?? {}
let opts = data.options ?? data.list ?? []
opts = Array.isArray(opts) ? opts : []
if (typeof props.labelFormatter === 'function') {
for (const key in opts) {
opts[key][props.field] = props.labelFormatter(opts[key], key)
}
}
state.options = opts
state.total = res.data.total ?? 0
state.total = data.total ?? 0
state.optionValidityFlag = state.keyword || (typeof initValue === 'object' ? !isEmpty(initValue) : initValue) ? false : true
})
.finally(() => {

View File

@@ -0,0 +1,40 @@
<template>
<div class="ba-ip-white-display">
<template v-if="displayIps.length">
<div v-for="(ip, idx) in displayIps" :key="idx" class="ba-ip-white-line">{{ ip }}</div>
</template>
<span v-else>-</span>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { computed } from 'vue'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const displayIps = computed(() => {
if (!cellValue || !Array.isArray(cellValue)) return []
return cellValue.map((item) => (typeof item === 'string' ? item : item?.value ?? '')).filter(Boolean)
})
</script>
<style scoped lang="scss">
.ba-ip-white-display {
white-space: pre-line;
line-height: 1.6;
}
.ba-ip-white-line {
margin: 2px 0;
}
</style>

View File

@@ -3,18 +3,18 @@
<el-switch
v-if="field.prop"
@change="onChange"
:model-value="cellValue"
:model-value="displayValue"
:loading="loading"
active-value="1"
inactive-value="0"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue, column, index })"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue: displayValue, column, index })"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { inject, ref } from 'vue'
import { inject, ref, onMounted } from 'vue'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
@@ -28,13 +28,27 @@ interface Props {
const loading = ref(false)
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const cellValue = ref(getCellValue(props.row, props.field, props.column, props.index))
if (typeof cellValue.value === 'number') {
cellValue.value = cellValue.value.toString()
const rawValue = getCellValue(props.row, props.field, props.column, props.index)
const normalized = (v: unknown) => {
if (typeof v === 'number') return v.toString()
if (v === null || v === undefined || v === '') return '0'
return String(v)
}
const displayValue = ref(normalized(rawValue))
const mountedAt = ref(0)
onMounted(() => {
mountedAt.value = Date.now()
})
const onChange = (value: string | number | boolean) => {
const newVal = String(value)
const prevVal = normalized(rawValue)
if (prevVal === newVal) return
if ([null, undefined, ''].includes(rawValue) && newVal === '0') return
if (Date.now() - mountedAt.value < 150) return
loading.value = true
baTable.api
.postData('edit', {
@@ -42,7 +56,7 @@ const onChange = (value: string | number | boolean) => {
[props.field.prop!]: value,
})
.then(() => {
cellValue.value = value
displayValue.value = newVal
baTable.onTableAction('field-change', { value: value, ...props })
})
.finally(() => {

View File

@@ -1,12 +1,16 @@
export default {
username: 'Username',
nickname: 'Nickname',
channel_id: 'Channel',
channel_name: 'Channel name',
'Please select channel': 'Please select channel',
group: 'Group',
avatar: 'Avatar',
email: 'Email',
mobile: 'Mobile Number',
'Last login': 'Last login',
Password: 'Password',
agent_id: 'agent',
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
'Personal signature': 'Personal Signature',
'Administrator login': 'Administrator Login Name',

View File

@@ -0,0 +1,15 @@
export default {
id: 'id',
name: 'name',
ip_white: 'ip_white',
ip_placeholder: 'Please enter IP address',
whitelist: 'Whitelist',
title: 'title',
remark: 'remark',
admin_id: 'admin_id',
admin__username: 'username',
secret: 'secret',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -0,0 +1,15 @@
export default {
id: 'id',
mall_user_id: 'mall_user_id',
malluser__username: 'username',
phone: 'phone',
region: 'region',
detail_address: 'detail_address',
address: 'address',
default_setting: 'default_setting',
'default_setting 0': 'default_setting 0',
'default_setting 1': 'default_setting 1',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -4,12 +4,14 @@ export default {
description: 'description',
remark: 'remark',
score: 'score',
'类型': '类型',
'类型 1': '类型 1',
'类型 2': '类型 2',
'类型 3': '类型 3',
type: 'type',
'type 1': 'type 1',
'type 2': 'type 2',
'type 3': 'type 3',
admin_id: 'admin_id',
admin__username: 'username',
image: 'show image',
stock: 'stock',
sort: 'sort',
create_time: 'create_time',
update_time: 'update_time',

View File

@@ -1,12 +1,16 @@
export default {
username: '用户名',
nickname: '昵称',
channel_id: '渠道',
channel_name: '渠道名称',
'Please select channel': '请选择渠道',
group: '角色组',
avatar: '头像',
email: '电子邮箱',
mobile: '手机号',
'Last login': '最后登录',
Password: '密码',
agent_id: '代理',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Administrator login': '管理员登录名',

View File

@@ -0,0 +1,15 @@
export default {
id: 'ID',
name: '渠道名',
ip_white: 'IP白名单',
ip_placeholder: '请输入IP地址',
whitelist: '白名单',
title: '标题',
remark: '备注',
admin_id: '管理员',
admin__username: '用户名',
secret: '密钥',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -0,0 +1,15 @@
export default {
id: 'ID',
mall_user_id: '用户',
malluser__username: '用户名',
phone: '电话',
region: '地区',
detail_address: '详细地址',
address: '地址',
default_setting: '默认地址',
'default_setting 0': '关',
'default_setting 1': '开',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -4,13 +4,18 @@ export default {
description: '描述',
remark: '备注',
score: '兑换积分',
'类型': '类型',
'类型 1': '奖励',
'类型 2': '充值',
'类型 3': '实物',
type: '类型',
'type 1': '奖励',
'type 2': '充值',
'type 3': '实物',
admin_id: '创建管理员',
admin__username: '用户名',
admin__username: '创建管理员',
image: '展示图',
stock: '库存',
sort: '排序',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',

View File

@@ -7,8 +7,8 @@ export default {
daily_claim: '每日限额',
daily_claim_use: '每日限额(已使用)',
available_for_withdrawal: '可提现金额',
admin_id: '归属管理员',
admin__username: '用户名',
admin_id: '归属管理员id',
admin__username: '归属管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',

View File

@@ -13,6 +13,7 @@ export const useAdminInfo = defineStore('adminInfo', {
token: '',
refresh_token: '',
super: false,
channel_id: 0,
}
},
actions: {

View File

@@ -113,6 +113,8 @@ export interface AdminInfo {
refresh_token: string
// 是否是 superAdmin用于判定是否显示终端按钮等不做任何权限判断
super: boolean
// 渠道ID创建子管理员时默认绑定
channel_id?: number
}
export interface UserInfo {

View File

@@ -48,7 +48,7 @@ const baTable = new baTableClass(
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', minWidth: 120, operator: false, render: 'tags' },
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
@@ -61,6 +61,20 @@ const baTable = new baTableClass(
operator: 'RANGE',
width: 160,
},
{
label: t('auth.admin.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 120,
operator: false,
},
{
label: t('auth.admin.agent_id'),
prop: 'agent_id',
align: 'center',
width: '160',
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),

View File

@@ -41,6 +41,19 @@
prop="nickname"
:placeholder="t('Please input field', { field: t('auth.admin.nickname') })"
/>
<FormItem
v-if="baTable.form.operate === 'Add' && adminInfo.super"
:label="t('auth.admin.channel_id')"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
type="remoteSelect"
:input-attr="{
pk: 'id',
field: 'name',
remoteUrl: '/admin/channel.Manage/index',
placeholder: t('auth.admin.Please select channel'),
}"
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
@@ -134,6 +147,17 @@ const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
channel_id: [
{
validator: (_rule: any, val: any, callback: Function) => {
if (baTable.form.operate === 'Add' && adminInfo.super && !val) {
return callback(new Error(t('auth.admin.Please select channel')))
}
return callback()
},
trigger: 'change',
},
],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],

View File

@@ -0,0 +1,182 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('channel.manage.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
<!-- 白名单弹窗 -->
<WhitelistPopup v-model:row="whitelistRow" @saved="baTable.getData()" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import WhitelistPopup from './whitelistPopup.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'channel/manage',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const whitelistRow = ref<TableRow | null>(null)
function openWhitelistDialog(row: TableRow) {
whitelistRow.value = row
}
const optButtons: OptButton[] = [
...defaultOptButtons(['edit', 'delete']),
{
render: 'tipButton',
name: 'whitelist',
title: 'channel.manage.whitelist',
text: '',
type: 'success',
icon: 'fa fa-list',
class: 'table-row-whitelist',
disabledTip: false,
display: (_row, _field) => baTable.auth('whitelist'),
click: (row: TableRow) => openWhitelistDialog(row),
},
]
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/channel.Manage/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('channel.manage.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('channel.manage.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.title'),
prop: 'title',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.admin__username'),
prop: 'admin.username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.manage.secret'),
prop: 'secret',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.remark'),
prop: 'remark',
align: 'center',
minWidth: '100',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.ip_white'),
prop: 'ip_white',
align: 'center',
minWidth: '120',
render: 'tags',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.manage.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 130, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { ip_white: [] },
before: {
onSubmit: () => {
const items = baTable.form.items
if (!items) return
// 从 popupForm 的 ipWhiteList 同步到 form.items避免 watch 循环)
const ipWhiteRef = baTable.form.extend?.ipWhiteListRef
if (ipWhiteRef?.value) {
items.ip_white = ipWhiteRef.value.filter((ip: string) => ip && String(ip).trim() !== '')
} else if (Array.isArray(items.ip_white)) {
items.ip_white = items.ip_white.filter((ip: string) => ip && String(ip).trim() !== '')
}
},
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,154 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('channel.manage.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('channel.manage.name') })"
/>
<el-form-item :label="t('channel.manage.ip_white')" prop="ip_white">
<div class="ba-ip-white-list">
<div class="ba-ip-white-item" v-for="(ip, idx) in ipWhiteList" :key="idx">
<el-input v-model="ipWhiteList[idx]" :placeholder="t('channel.manage.ip_placeholder')" clearable />
<el-button @click="onDelIpWhite(idx)" size="small" icon="el-icon-Delete" circle />
</div>
<el-button v-blur class="ba-add-ip-white" @click="onAddIpWhite" icon="el-icon-Plus">{{ t('Add') }}</el-button>
</div>
</el-form-item>
<FormItem
:label="t('channel.manage.title')"
type="string"
v-model="baTable.form.items!.title"
prop="title"
:placeholder="t('Please input field', { field: t('channel.manage.title') })"
/>
<FormItem
:label="t('channel.manage.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('channel.manage.remark') })"
/>
<FormItem
:label="t('channel.manage.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }"
:placeholder="t('Please select field', { field: t('channel.manage.admin_id') })"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip] */
function normalizeIpWhite(val: unknown): string[] {
if (!val || !Array.isArray(val)) return []
return val.map((item) => (typeof item === 'string' ? item : (item?.value ?? '')))
}
const ipWhiteList = ref<string[]>([])
// 仅当表单加载时从 form.items 同步到 ipWhiteList避免双向 watch 导致循环更新
watch(
() => baTable.form.items?.ip_white,
(val) => {
ipWhiteList.value = normalizeIpWhite(val)
},
{ immediate: true }
)
const onAddIpWhite = () => {
ipWhiteList.value.push('')
}
const onDelIpWhite = (idx: number) => {
ipWhiteList.value.splice(idx, 1)
}
// 将 ipWhiteList 暴露给 baTable供提交时同步
if (!baTable.form.extend) baTable.form.extend = {}
baTable.form.extend.ipWhiteListRef = ipWhiteList
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('channel.manage.name') })],
title: [buildValidatorData({ name: 'required', title: t('channel.manage.title') })],
admin_id: [buildValidatorData({ name: 'required', title: t('channel.manage.admin_id') })],
create_time: [buildValidatorData({ name: 'date', title: t('channel.manage.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('channel.manage.update_time') })],
})
</script>
<style scoped lang="scss">
.ba-ip-white-list {
.ba-ip-white-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.el-input {
flex: 1;
}
}
.ba-add-ip-white {
margin-top: 4px;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="!!row"
:title="t('channel.manage.whitelist')"
@close="close"
>
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form">
<div class="ba-ip-white-list">
<div class="ba-ip-white-item" v-for="(ip, idx) in ipWhiteList" :key="idx">
<el-input v-model="ipWhiteList[idx]" :placeholder="t('channel.manage.ip_placeholder')" clearable />
<el-button @click="onDelIpWhite(idx)" size="small" icon="el-icon-Delete" circle />
</div>
<el-button v-blur class="ba-add-ip-white" @click="onAddIpWhite" icon="el-icon-Plus">{{ t('Add') }}</el-button>
</div>
</div>
</el-scrollbar>
<template #footer>
<el-button @click="close">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="submitLoading" @click="onSave" type="primary">{{ t('Save') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
const { t } = useI18n()
const props = defineProps<{
row: TableRow | null
}>()
const emit = defineEmits<{
(e: 'update:row', val: TableRow | null): void
(e: 'saved'): void
}>()
const api = new baTableApi('/admin/channel.Manage/')
const loading = ref(false)
const submitLoading = ref(false)
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip] */
function normalizeIpWhite(val: unknown): string[] {
if (!val || !Array.isArray(val)) return []
return val.map((item) => (typeof item === 'string' ? item : item?.value ?? ''))
}
const ipWhiteList = ref<string[]>([])
watch(
() => props.row,
(val) => {
ipWhiteList.value = val ? normalizeIpWhite(val.ip_white) : []
},
{ immediate: true }
)
const onAddIpWhite = () => {
ipWhiteList.value.push('')
}
const onDelIpWhite = (idx: number) => {
ipWhiteList.value.splice(idx, 1)
}
const close = () => {
emit('update:row', null)
}
const onSave = async () => {
if (!props.row) return
const ips = ipWhiteList.value.filter((ip) => ip.trim() !== '')
submitLoading.value = true
try {
await api.postData('edit', {
id: props.row.id,
ip_white: ips,
})
emit('saved')
close()
} finally {
submitLoading.value = false
}
}
</script>
<style scoped lang="scss">
.ba-ip-white-list {
.ba-ip-white-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.el-input {
flex: 1;
}
}
.ba-add-ip-white {
margin-top: 4px;
}
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.address.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/address',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.Address/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.address.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.address.malluser__username'),
prop: 'mallUser.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('mall.address.phone'),
prop: 'phone',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.address.region'), prop: 'region_text', align: 'center', operator: false },
{
label: t('mall.address.detail_address'),
prop: 'detail_address',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.address.default_setting'),
prop: 'default_setting',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('mall.address.default_setting 0'), '1': t('mall.address.default_setting 1') },
},
{
label: t('mall.address.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('mall.address.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined, 'default_setting'],
},
{
defaultItems: {},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,114 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('mall.address.mall_user_id')"
type="remoteSelect"
v-model="baTable.form.items!.mall_user_id"
prop="mall_user_id"
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
:placeholder="t('Please select field', { field: t('mall.address.mall_user_id') })"
/>
<FormItem
:label="t('mall.address.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('mall.address.phone') })"
/>
<FormItem
:label="t('mall.address.region')"
type="city"
v-model="baTable.form.items!.region"
prop="region"
:placeholder="t('Please select field', { field: t('mall.address.region') })"
/>
<FormItem
:label="t('mall.address.detail_address')"
type="string"
v-model="baTable.form.items!.detail_address"
prop="detail_address"
:placeholder="t('Please input field', { field: t('mall.address.detail_address') })"
/>
<FormItem
:label="t('mall.address.address')"
type="textarea"
v-model="baTable.form.items!.address"
prop="address"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.address.address') })"
/>
<FormItem
:label="t('mall.address.default_setting')"
type="switch"
v-model="baTable.form.items!.default_setting"
prop="default_setting"
:input-attr="{ content: { '0': t('mall.address.default_setting 0'), '1': t('mall.address.default_setting 1') } }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
phone: [buildValidatorData({ name: 'required', title: t('mall.address.phone') })],
create_time: [buildValidatorData({ name: 'date', title: t('mall.address.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('mall.address.update_time') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -48,20 +48,69 @@ const baTable = new baTableClass(
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.item.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.item.title'), prop: 'title', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.item.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('mall.item.类型'),
prop: '类型',
label: t('mall.item.description'),
prop: 'description',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.score'),
prop: 'score',
align: 'center',
sortable: false,
operator: 'RANGE',
},
{
label: t('mall.item.type'),
prop: 'type',
align: 'center',
effect: 'dark',
custom: { 1: 'success', 2: 'primary', 3: 'info' },
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: { '1': t('mall.item.类型 1'), '2': t('mall.item.类型 2'), '3': t('mall.item.类型 3') },
replaceValue: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') },
},
{
label: t('mall.item.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') },
},
{
label: t('mall.item.remark'),
prop: 'remark',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.image'),
prop: 'image',
align: 'center',
render: 'image',
operator: false,
},
{
label: t('mall.item.stock'),
prop: 'stock',
align: 'center',
sortable: false,
operator: 'RANGE',
},
{
label: t('mall.item.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
@@ -92,10 +141,13 @@ const baTable = new baTableClass(
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {},
defaultItems: {
stock: 0,
sort: 100,
},
}
)

View File

@@ -65,20 +65,21 @@
:placeholder="t('Please input field', { field: t('mall.item.score') })"
/>
<FormItem
:label="t('mall.item.类型')"
:label="t('mall.item.type')"
type="select"
v-model="baTable.form.items!.类型"
prop="类型"
:input-attr="{ content: { '1': t('mall.item.类型 1'), '2': t('mall.item.类型 2'), '3': t('mall.item.类型 3') } }"
:placeholder="t('Please select field', { field: t('mall.item.类型') })"
v-model="baTable.form.items!.type"
prop="type"
:input-attr="{ content: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') } }"
:placeholder="t('Please select field', { field: t('mall.item.type') })"
/>
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
<FormItem
:label="t('mall.item.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }"
:placeholder="t('Please select field', { field: t('mall.item.admin_id') })"
:label="t('mall.item.stock')"
type="number"
v-model="baTable.form.items!.stock"
prop="stock"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
/>
<FormItem
:label="t('mall.item.sort')"
@@ -88,6 +89,13 @@
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.sort') })"
/>
<FormItem
:label="t('mall.item.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') } }"
/>
</el-form>
</div>
</el-scrollbar>
@@ -124,7 +132,7 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
buildValidatorData({ name: 'number', title: t('mall.item.score') }),
buildValidatorData({ name: 'required', title: t('mall.item.score') }),
],
类型: [buildValidatorData({ name: 'required', title: t('mall.item.类型') })],
type: [buildValidatorData({ name: 'required', title: t('mall.item.type') })],
sort: [buildValidatorData({ name: 'number', title: t('mall.item.sort') })],
create_time: [buildValidatorData({ name: 'date', title: t('mall.item.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('mall.item.update_time') })],

View File

@@ -41,7 +41,7 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.pints.Order/'),
new baTableApi('/admin/mall.PintsOrder/'),
{
pk: 'id',
column: [
@@ -59,6 +59,7 @@ const baTable = new baTableClass(
label: t('mall.pintsOrder.malluser__username'),
prop: 'mallUser.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
@@ -68,6 +69,8 @@ const baTable = new baTableClass(
label: t('mall.pintsOrder.type'),
prop: 'type',
align: 'center',
effect: 'dark',
custom: { 1: 'success', 2: 'primary', 3: 'info' },
operator: 'eq',
sortable: false,
render: 'tag',

View File

@@ -41,7 +41,7 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.redemption.Order/'),
new baTableApi('/admin/mall.RedemptionOrder/'),
{
pk: 'id',
column: [
@@ -59,6 +59,7 @@ const baTable = new baTableClass(
label: t('mall.redemptionOrder.malluser__username'),
prop: 'mallUser.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
@@ -68,9 +69,10 @@ const baTable = new baTableClass(
label: t('mall.redemptionOrder.status'),
prop: 'status',
align: 'center',
custom: { 0: 'info', 1: 'primary' },
operator: 'eq',
sortable: false,
render: 'switch',
render: 'tag',
replaceValue: { '0': t('mall.redemptionOrder.status 0'), '1': t('mall.redemptionOrder.status 1') },
},
{
@@ -102,6 +104,8 @@ const baTable = new baTableClass(
label: t('mall.redemptionOrder.type'),
prop: 'type',
align: 'center',
effect: 'dark',
custom: { 1: 'success', 2: 'primary', 3: 'info' },
operator: 'eq',
sortable: false,
render: 'tag',

View File

@@ -47,21 +47,61 @@ const baTable = new baTableClass(
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.user.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{
label: t('mall.user.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.user.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.daily_claim'), prop: 'daily_claim', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.daily_claim_use'), prop: 'daily_claim_use', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.available_for_withdrawal'), prop: 'available_for_withdrawal', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.admin__username'), prop: 'admin.username', align: 'center', operatorPlaceholder: t('Fuzzy query'), render: 'tags', operator: 'LIKE', comSearchRender: 'string' },
{ label: t('mall.user.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('mall.user.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('mall.user.admin__username'),
prop: 'admin.username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('mall.user.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('mall.user.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {},
defaultItems: {
score: 0,
daily_claim: 100,
daily_claim_use: 0,
available_for_withdrawal: 0,
},
}
)

View File

@@ -17,7 +17,7 @@
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '':'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
@@ -29,14 +29,59 @@
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem :label="t('mall.user.username')" type="string" v-model="baTable.form.items!.username" prop="username" :placeholder="t('Please input field', { field: t('mall.user.username') })" />
<FormItem :label="t('mall.user.phone')" type="string" v-model="baTable.form.items!.phone" prop="phone" :placeholder="t('Please input field', { field: t('mall.user.phone') })" />
<FormItem :label="t('mall.user.password')" type="password" v-model="baTable.form.items!.password" prop="password" :placeholder="t('Please input field', { field: t('mall.user.password') })" />
<FormItem :label="t('mall.user.score')" type="number" v-model="baTable.form.items!.score" prop="score" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.score') })" />
<FormItem :label="t('mall.user.daily_claim')" type="number" v-model="baTable.form.items!.daily_claim" prop="daily_claim" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.daily_claim') })" />
<FormItem :label="t('mall.user.daily_claim_use')" type="number" v-model="baTable.form.items!.daily_claim_use" prop="daily_claim_use" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.daily_claim_use') })" />
<FormItem :label="t('mall.user.available_for_withdrawal')" type="number" v-model="baTable.form.items!.available_for_withdrawal" prop="available_for_withdrawal" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.available_for_withdrawal') })" />
<FormItem :label="t('mall.user.admin_id')" type="remoteSelect" v-model="baTable.form.items!.admin_id" prop="admin_id" :input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }" :placeholder="t('Please select field', { field: t('mall.user.admin_id') })" />
<FormItem
:label="t('mall.user.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('mall.user.username') })"
/>
<FormItem
:label="t('mall.user.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('mall.user.phone') })"
/>
<FormItem
:label="t('mall.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('mall.user.password') })"
/>
<FormItem
:label="t('mall.user.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.score') })"
/>
<FormItem
:label="t('mall.user.daily_claim')"
type="number"
v-model="baTable.form.items!.daily_claim"
prop="daily_claim"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.daily_claim') })"
/>
<FormItem
:label="t('mall.user.daily_claim_use')"
type="number"
v-model="baTable.form.items!.daily_claim_use"
prop="daily_claim_use"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.daily_claim_use') })"
/>
<FormItem
:label="t('mall.user.available_for_withdrawal')"
type="number"
v-model="baTable.form.items!.available_for_withdrawal"
prop="available_for_withdrawal"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.available_for_withdrawal') })"
/>
</el-form>
</div>
</el-scrollbar>