* admin ui

Co-authored-by: pushuo <pushuo@opencart.cn>
This commit is contained in:
Sam Chen 2022-05-07 18:20:39 +08:00 committed by GitHub
parent 71be6d9bfd
commit bc91c4a323
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2120 additions and 388 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ yarn-error.log
/.idea
/.vscode
mix-manifest.json
package-lock.json

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Models\AdminUser;
use Illuminate\Console\Command;
class MakeRootAdminUser extends Command
{
protected $signature = 'make:admin';
protected $description = '生成第 1 个 root admin 账号';
public function handle()
{
$email = $this->ask('请输入登录邮箱地址');
$password = $this->ask('请输入密码');
if (!$email || !$password) {
$this->info('邮箱地址/手机号码不能为空,退出');
return;
}
$admin = AdminUser::create([
'name' => 'John Doe',
'email' => $email,
'password' => bcrypt($password),
'active' => true,
]);
$this->info('账号创建成功,退出');
}
}

View File

@ -1,5 +1,20 @@
<?php
function admin_route($route, $params = []): string
{
return route('admin.' . $route, $params);
}
function shop_route($route, $params = []): string
{
return route('shop.' . $route, $params);
}
function thumbnail($path): string
{
return 'https://dummyimage.com/100.jpg';
}
function locales(): array
{
$locales = [];
@ -14,3 +29,9 @@ function locales(): array
return $locales;
}
function locale(): string
{
return 'zh_cn';
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CategoryRequest;
use App\Http\Resources\Admin\CategoryResource;
use App\Models\Category;
use App\Services\CategoryService;
use Illuminate\Http\Request;
class CategoriesController extends Controller
{
public function index()
{
$categories = Category::with('description', 'children.description', 'children.children.description')
->where('parent_id', 0)
->get();
$data = [
'categories' => CategoryResource::collection($categories),
];
return view('admin.pages.categories.index', $data);
}
public function create(Request $request)
{
$category = new Category();
$data = [
'category' => $category,
];
return view('admin.pages.categories.form', $data);
}
public function store(CategoryRequest $request)
{
$redirect = $request->_redirect ?? admin_route('categories.index');
$category = (new CategoryService())->create($request->all());
return redirect($redirect)->with('success', 'Category created successfully');
}
public function edit(Category $category)
{
$descriptions = $category->descriptions->keyBy('locale');
$data = [
'category' => $category,
'descriptions' => $descriptions,
];
return view('admin.pages.categories.form', $data);
}
public function update(CategoryRequest $request, Category $category)
{
$redirect = $request->_redirect ?? admin_route('categories.index');
$category = (new CategoryService())->update($category, $request->all());
return redirect($redirect)->with('success', 'Category created successfully');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AdminUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function show()
{
return view('admin.pages.login.login');
}
public function store(Request $request)
{
$this->validate($request, [
'email' => 'required|email',
'password' => 'required'
]);
$credentials = $request->only('email', 'password');
if (auth(AdminUser::AUTH_GUARD)->attempt($credentials)) {
return redirect(admin_route('home.index'));
}
return redirect()->back()->withErrors(['email' => 'Invalid credentials']);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AdminUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LogoutController extends Controller
{
public function index(Request $request)
{
Auth::guard(AdminUser::AUTH_GUARD)->logout();
return redirect(admin_route('login.show'));
}
}

View File

@ -3,29 +3,34 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\ProductResource;
use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ProductsController extends Controller
{
public function index()
public function index(Request $request)
{
$products = Product::query()
$query = Product::query()
->with('description')
->withCount('skus')
->paginate();
->withCount('skus');
if ($request->trashed) {
$query->onlyTrashed();
}
$products = $query->paginate();
$data = [
'products' => $products,
'products' => ProductResource::collection($products),
];
return view('admin.pages.products.index', $data);
}
public function create()
public function create(Request $request)
{
return view('admin.pages.products.form.form');
}
@ -33,7 +38,8 @@ class ProductsController extends Controller
{
$product = (new ProductService)->create($request->all());
return redirect()->route('admin.products.index')->with('success', 'product created');
$redirect = $request->_redirect ?? route('admin.products.index');
return redirect($redirect)->with('success', 'product created');
}
public function show($id)
@ -58,8 +64,18 @@ class ProductsController extends Controller
return redirect()->route('admin.products.index')->with('success', 'product updated');
}
public function destroy($id)
public function destroy(Request $request, Product $product)
{
//
$product->delete();
return ['success' => true];
}
public function restore(Request $request)
{
$productId = $request->id ?? 0;
Product::withTrashed()->find($productId)->restore();
return ['success' => true];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Http\Request;
class CategoriesController extends Controller
{
public function show(Request $request, Category $category)
{
$products = Product::query()->paginate();
$data = [
'category' => $category,
'products' => $products,
];
return view('category', $data);
}
}

View File

@ -3,30 +3,19 @@
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Plugin\Guangda\Seller\Models\Product;
class HomeController extends Controller
{
public function index()
{
$categories = Category::with('description')->where('active', 1)->get();
$data = [
'price' => 1,
'status' => true,
'seller_id' => 100,
'categories' => $categories,
];
$product = new Product($data);
$payments = [
'\Plugin\Guangda\WeChat\WeChat',
'\Plugin\Guangda\Alipay\Alipay',
];
$data['payments'] = [];
foreach ($payments as $payment) {
$data['payments'][] = (new $payment)->handle();
}
return view('home', $data);
}
}

View File

@ -20,7 +20,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
// \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**

View File

@ -15,7 +15,8 @@ class Authenticate extends Middleware
protected function redirectTo($request)
{
if (! $request->expectsJson()) {
return route('login');
// 后台
return admin_route('login.show');
}
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class CategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'descriptions.*.name' => 'required|max:255',
];
}
public function attributes()
{
return [
'descriptions.*.name' => '名称',
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources\Admin;
use Illuminate\Http\Resources\Json\JsonResource;
class CategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$data = [
'id' => $this->id,
'name' => $this->description->name ?? '',
'parent_id' => $this->parent_id,
'position' => $this->position,
'active' => $this->active,
'url_edit' => admin_route('categories.edit', $this),
'children' => CategoryResource::collection($this->children),
];
return $data;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources\Admin;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
$data = [
'id' => $this->id,
'image' => thumbnail($this->image),
'name' => $this->description->name ?? '',
'price_formatted' => $this->price_formatted,
'active' => $this->active,
'created_at' => (string)$this->created_at,
'deleted_at' => (string)$this->deleted_at,
'url_edit' => route('admin.products.edit', $this->id),
];
return $data;
}
}

15
app/Models/AdminUser.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
class AdminUser extends Authenticatable
{
use HasFactory;
const AUTH_GUARD = 'web_admin';
protected $fillable = ['name', 'email', 'password', 'active'];
}

41
app/Models/Category.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'parent_id',
'position',
'active',
];
protected $casts = [
'active' => 'boolean',
];
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function descriptions()
{
return $this->hasMany(CategoryDescription::class);
}
public function description()
{
return $this->hasOne(CategoryDescription::class)->where('locale', locale());
}
public function paths()
{
return $this->hasMany(CategoryPath::class);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CategoryDescription extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'locale',
'name',
'content',
'meta_title',
'meta_description',
'meta_keywords',
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CategoryPath extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'path_id',
'level',
];
}

View File

@ -4,16 +4,24 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = ['image', 'video', 'position', 'active', 'variables'];
protected $attributes = [
'image' => ''
];
protected $casts = [
'active' => 'boolean',
];
public function description()
{
return $this->hasOne(ProductDescription::class)->where('locale', 'zh_cn');
return $this->hasOne(ProductDescription::class)->where('locale', locale());
}
public function descriptions()
@ -26,6 +34,11 @@ class Product extends Model
return $this->hasMany(ProductSku::class);
}
public function getPriceFormattedAttribute(): string
{
return '$' . $this->price;
}
public function getVariablesDecodedAttribute()
{
return json_decode($this->variables, true);

View File

@ -1,44 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
}

View File

@ -46,7 +46,7 @@ class RouteServiceProvider extends ServiceProvider
// Shop
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
->group(base_path('routes/web_shop.php'));
// Admin
Route::middleware('web')

View File

@ -0,0 +1,156 @@
<?php
/**
* CategoryService.php
*
* @copyright 2022 opencart.cn - All Rights Reserved
* @link http://www.guangdawangluo.com
* @author Sam Chen <sam.chen@opencart.cn>
* @created 2022-05-07 15:15:25
* @modified 2022-05-07 15:15:25
*/
namespace App\Services;
use App\Models\Category;
use App\Models\CategoryPath;
use Illuminate\Support\Facades\DB;
class CategoryService
{
public function create(array $data)
{
try {
DB::beginTransaction();
$category = new \App\Models\Category();
$category->fill($data);
$category->saveOrFail();
$descriptions = [];
foreach ($data['descriptions'] as $description) {
$descriptions[] = [
'locale' => $description['locale'],
'name' => $description['name'],
'content' => $description['content'] ?? '',
'meta_title' => $description['meta_title'] ?? '',
'meta_description' => $description['meta_description'] ?? '',
'meta_keyword' => $description['meta_keyword'] ?? '',
];
}
$category->descriptions()->createMany($descriptions);
$this->createPath($category);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
return $category;
}
public function update(Category $category, array $data)
{
try {
DB::beginTransaction();
$category->updateOrFail($data);
$descriptions = [];
foreach ($data['descriptions'] as $description) {
$descriptions[] = [
'locale' => $description['locale'],
'name' => $description['name'],
'content' => $description['content'] ?? '',
'meta_title' => $description['meta_title'] ?? '',
'meta_description' => $description['meta_description'] ?? '',
'meta_keyword' => $description['meta_keyword'] ?? '',
];
}
$category->descriptions()->delete();
$category->descriptions()->createMany($descriptions);
$this->updatePath($category);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
return $category;
}
public function createPath(Category $category)
{
// Paths
$paths = [];
// 复制上级分类的 paths
$level = 0;
$parentPaths = CategoryPath::query()->where('category_id', $category->parent_id)->orderBy('level')->get();
foreach ($parentPaths as $path) {
$paths[] = [
'path_id' => $path->path_id,
'level' => $level,
];
$level++;
}
// 自身
$paths[] = [
'path_id' => $category->id,
'level' => $level,
];
$category->paths()->createMany($paths);
}
public function updatePath(Category $category)
{
$categoryPaths = CategoryPath::query()
->where('path_id', $category->id)
->orderBy('level')
->get();
// Get the nodes new parents
$newParentPathIds = CategoryPath::query()
->where('category_id', $category->parent_id)
->orderBy('level')
->pluck('path_id')
->toArray();
$paths = [];
if ($categoryPaths->count()) {
foreach ($categoryPaths as $category_path) {
$newPathIds = $newParentPathIds;
$results = CategoryPath::query()
->where('category_id', (int)$category_path->category_id)
->where('level', '>=', $category_path->level)
->orderBy('level')
->get();
foreach ($results as $result) {
$newPathIds[] = $result->path_id;
}
$level = 0;
foreach ($newPathIds as $path_id) {
$paths[] = [
'category_id' => $category_path->category_id,
'path_id' => $path_id,
'level' => $level,
'created_at' => now(),
'updated_at' => now(),
];
$level++;
}
}
}
CategoryPath::query()
->whereIn('category_id', $categoryPaths->pluck('category_id'))
->delete();
CategoryPath::insert($paths);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\View\Components\Admin;
use Illuminate\View\Component;
class Header extends Component
{
public array $links = [];
/**
* Create a new component instance.
*
* @return void
*/
public function __construct()
{
$this->addLink('管理首页', admin_route('home.index'), true);
$this->addLink('订单管理', admin_route('home.index'));
$this->addLink('商品管理', admin_route('products.index'));
$this->addLink('会员管理', admin_route('home.index'));
$this->addLink('营销管理', admin_route('home.index'));
$this->addLink('系统设置', admin_route('home.index'));
}
/**
* Get the view / contents that represent the component.
*
* @return \Illuminate\Contracts\View\View|\Closure|string
*/
public function render()
{
return view('components.admin.header');
}
public function addLink($title, $url, $active = false)
{
$this->links[] = [
'title' => $title,
'url' => $url,
'active' => $active
];
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\View\Components\Admin;
use Illuminate\Support\Str;
use Illuminate\View\Component;
class Sidebar extends Component
{
public array $links = [];
/**
* Create a new component instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*
* @return \Illuminate\Contracts\View\View|\Closure|string
*/
public function render()
{
$routeName = request()->route()->getName();
if (Str::startsWith($routeName, ['admin.products.', 'admin.categories.'])) {
$this->addLink('商品分类', admin_route('categories.index'), 'fa fa-tachometer-alt', false);
$this->addLink('商品列表', admin_route('products.index'), 'fa fa-tachometer-alt', false);
$this->addLink('回收站', admin_route('products.index', ['trashed' => 1]), 'fa fa-tachometer-alt', false);
}
return view('components.admin.sidebar');
}
public function addLink($title, $url, $icon, $active)
{
$this->links[] = [
'title' => $title,
'url' => $url,
'icon' => $icon,
'active' => $active
];
}
}

View File

@ -1,5 +1,7 @@
<?php
use App\Models\AdminUser;
return [
/*
@ -40,6 +42,11 @@ return [
'driver' => 'session',
'provider' => 'users',
],
AdminUser::AUTH_GUARD => [
'driver' => 'session',
'provider' => 'admins',
],
],
/*
@ -69,6 +76,11 @@ return [
// 'driver' => 'database',
// 'table' => 'users',
// ],
'admins' => [
'driver' => 'eloquent',
'model' => AdminUser::class,
],
],
/*

View File

@ -13,19 +13,56 @@ class CreateTables extends Migration
*/
public function up()
{
Schema::create('admin_users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->boolean('active');
$table->timestamps();
});
Schema::create('categories', function (Blueprint $table) {
$table->id()->startingValue(100_000);
$table->unsignedBigInteger('parent_id')->default(0);
$table->integer('position')->default(0);
$table->boolean('active');
$table->timestamps();
});
Schema::create('category_descriptions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('category_id');
$table->string('locale');
$table->string('name');
$table->text('content');
$table->string('meta_title')->default('');
$table->string('meta_description')->default('');
$table->string('meta_keyword')->default('');
$table->timestamps();
});
Schema::create('category_paths', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('category_id');
$table->unsignedBigInteger('path_id');
$table->integer('level');
$table->timestamps();
});
Schema::create('products', function (Blueprint $table) {
$table->id()->startingValue(100_000);
$table->string('image')->default('');
$table->string('video')->default('');
$table->integer('position')->default(0);
$table->boolean('active');
$table->boolean('active')->default(0);
$table->json('variables')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('product_descriptions', function (Blueprint $table) {
$table->id()->startingValue(100_000);
$table->id();
$table->unsignedBigInteger('product_id');
$table->string('locale');
$table->string('name');
@ -39,7 +76,7 @@ class CreateTables extends Migration
Schema::create('product_skus', function (Blueprint $table) {
$table->id()->startingValue(100_000);
$table->unsignedBigInteger('product_id');
$table->string('variants')->default(0);
$table->string('variants')->nullable();
$table->integer('position')->default(0);
$table->string('image')->default('');
$table->string('model')->default('');
@ -61,6 +98,14 @@ class CreateTables extends Migration
$table->unsignedInteger('quantity');
$table->timestamps();
});
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('value');
$table->boolean('json')->default(false);
$table->timestamps();
});
}
/**
@ -70,9 +115,14 @@ class CreateTables extends Migration
*/
public function down()
{
Schema::dropIfExists('admin_users');
Schema::dropIfExists('categories');
Schema::dropIfExists('category_descriptions');
Schema::dropIfExists('category_paths');
Schema::dropIfExists('products');
Schema::dropIfExists('product_descriptions');
Schema::dropIfExists('product_skus');
Schema::dropIfExists('carts');
Schema::dropIfExists('settings');
}
}

View File

@ -1,34 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('value');
$table->boolean('json')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('settings');
}
}

View File

@ -11,8 +11,11 @@
},
"devDependencies": {
"axios": "^0.21",
"bootstrap": "^4.6.1",
"laravel-mix": "^6.0.6",
"lodash": "^4.17.19",
"postcss": "^8.1.14"
"resolve-url-loader": "^4.0.0",
"sass": "^1.38.1",
"sass-loader": "^12.1.0"
}
}

View File

@ -0,0 +1,154 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-10-13 10:38:17
//
hr.horizontal {
background-color: transparent;
height: 1px;
margin: 1rem 0;
color: inherit;
border: 0;
opacity: .25;
}
hr.horizontal.dark {
background-image: linear-gradient(90deg,transparent,rgba(0,0,0,.4),transparent);
}
.card {
// box-shadow: 0 20px 27px 0 rgba(0, 0, 0, .05);
box-shadow: 0 0.75rem 1.5rem rgb(18 38 63 / 3%);
// border: 0 solid rgba(0,0,0,.125);
border: 1px solid #edf2f9;
// border-radius: 0.5rem;
.card-header {
padding: 1rem 1rem .3rem;
background-color: #fff;
font-weight: bold;
font-size: .8rem;
border-bottom: 0 solid rgba(0,0,0,.125);
&:first-child {
border-radius: 1rem 1rem 0 0;
}
}
.card-body {
padding: 1rem;
}
}
table.table {
thead {
th {
background-color: #f9fbfd;
color: #74859e;
font-size: .825rem;
border-top-width: 0;
border-bottom: none;
white-space:nowrap;
}
}
td {
border-bottom: 0;
font-size: .8125rem;
vertical-align: middle;
border-top: 1px solid #edf2f9;
}
&.table-striped>tbody>tr {
&:nth-of-type(odd) {
background: transparent;
}
&:nth-of-type(2n) {
background: #f9fbfd;
}
}
}
.form-group {
margin-bottom: 1.375rem;
}
.btn {
font-size: 0.8rem;
}
.btn-check {
position: absolute;
clip: rect(0,0,0,0);
pointer-events: none;
}
.btn-group-radio {
.btn-group {
label {
&:first-of-type {
border-top-left-radius: .25rem;
border-bottom-left-radius: .25rem;
}
}
}
.btn-check:active+.btn-outline-primary, .btn-check:checked+.btn-outline-primary, .btn-outline-primary.active, .btn-outline-primary.dropdown-toggle.show, .btn-outline-primary:active {
color: #fff;
background-color: #0d6efd;
border-color: #0d6efd;
}
&.btn-group-radio-pay {
.btn-check:active+.btn, .btn-check:checked+.btn, .btn.active, .btn.dropdown-toggle.show, .btn:active {
color: #fff;
background-color: transparent;
padding: .32rem .70rem;
border: 2px solid #0d6efd;
// border-color: #0d6efd;
}
.btn-group-radios {
display: flex;
}
label.btn {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ddd;
&:hover {
border-color: #0d6efd;
}
}
img {
max-width: 140px;
height: 42px;
}
}
}
.bd-callout {
padding: 1.25rem;
margin-top: 1.25rem;
margin-bottom: 1.25rem;
border: 1px solid #eee;
border-left-width: .25rem;
border-radius: .25rem;
&.bd-callout-info {
border-left-color: #5bc0de;
}
p {
margin-bottom: 0;
}
}

10
resources/css/admin/_dashboard.scss vendored Normal file
View File

@ -0,0 +1,10 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//

170
resources/css/admin/_global.scss vendored Normal file
View File

@ -0,0 +1,170 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
body {
// min-height: 100vh;
// background-color: #f9f9f9;
font-weight: 400;
line-height: 1.6;
font-size: 0.875rem;
background-color: #f9fbfd;
}
@for $i from 1 through 6 {
.min-h#{$i} {
min-height: #{$i}00px;
}
}
.main-content {
display: flex;
// flex-direction: column;
// min-height: 100vh;
transition: margin-left .25s ease-in-out,left .25s ease-in-out,margin-right .25s ease-in-out,right .25s ease-in-out;
width: 100%;
height: calc(100vh - 60px);
overflow: hidden;
@media screen and (max-width: 991px) {
margin-left: 260px;
}
&:not(.active) {
margin-left: 0;
}
> #content {
flex: 1;
padding: 1rem;
overflow-y: auto;
@media screen and (max-width: 991px) {
padding: 0 1rem 1.5rem;
}
}
}
.page-title-box {
.page-title {
font-size: 15px;
margin: 0;
// line-height: 35px;
margin-bottom: 20px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: inherit;
}
}
.switch-plus {
position: relative;
width: 50px;
height: 24px;
margin-bottom: 1rem;
input {
position: absolute;
top: 0;
z-index: 2;
opacity: 0;
cursor: pointer;
height: 20px;
width: 50px;
left: 0;
margin: 0;
&:checked {
z-index: 1;
& + label {
opacity: 1;
cursor: default;
&:hover {
opacity: 0.5;
}
}
~ .toggle-outside .toggle-inside {
left: 0.25rem;
background-color: #fff;
box-shadow: 0 3px 6px 0 rgba(140,152,164,.25);
}
}
& ~ input:checked ~ .toggle-outside {
background-color: $main_color;
.toggle-inside {
left: 23px;
background-color: #fff;
}
}
}
label {
color: #fff;
opacity: 0.33;
transition: opacity 0.25s ease;
cursor: pointer;
font-size: 1.5rem;
line-height: 3rem;
display: inline-block;
width: 50px;
height: 100%;
margin: 0;
text-align: center;
&:last-of-type {
margin-left: 50px;
}
}
.toggle-outside {
height: 100%;
border-radius: 2rem;
padding: 2px;
overflow: hidden;
transition: 0.25s ease all;
// background: #f1f3fa;
background-color: #e7eaf3;
position: absolute;
width: 46px;
left: 0;
}
.toggle-inside {
border-radius: 50%;
// background: #4a4a4a;
position: absolute;
transition: 0.25s ease all;
height: 20px;
width: 20px;
}
}
body.page-seller-product {
.share-link-pop {
.share-links-td {
padding-top: 4px;
padding-bottom: 4px;
}
.share-links-code {
width: 80px;
height: 80px;
overflow: hidden;
img {
max-width: 100%;
}
}
}
}

75
resources/css/admin/_header.scss vendored Normal file
View File

@ -0,0 +1,75 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
.header-wrap {
background-color: #fff;
height: 60px;
border-bottom: 1px solid #f1f1f1;
display: flex;
align-items: center;
.header-left {
width: 190px;
}
.header-right {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
.navbar {
margin-bottom: 0;
list-style: none;
li {
padding: 0.5rem 1.3rem;
a {
color: #333;
padding: 0.5rem 0;
}
}
}
.navbar.navbar-right {
li {
a {
position: relative;
&:after {
content: '';
position: absolute;
left: 0;
display: none;
bottom: 0;
width: 100%;
height: 3px;
background-color: $main_color;
}
}
&.active, &:hover {
a {
&:after {
display: block;
}
}
}
}
}
}
.avatar {
height: 36px;
margin-bottom: -15px;
margin-top: -15px;
width: 36px;
}
}

27
resources/css/admin/_iconfont.scss vendored Normal file
View File

@ -0,0 +1,27 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
@font-face {
font-family: 'iconfont'; /* Project id 2787822 */
src: url('//at.alicdn.com/t/font_2787822_7mtbg56vojp.woff2?t=1634612961708') format('woff2'),
url('//at.alicdn.com/t/font_2787822_7mtbg56vojp.woff?t=1634612961708') format('woff'),
url('//at.alicdn.com/t/font_2787822_7mtbg56vojp.ttf?t=1634612961708') format('truetype');
}
.iconfont {
font-family:"iconfont" !important;
font-size:16px;
font-style:normal;
-webkit-font-smoothing: antialiased;
// -webkit-text-stroke-width: 0.2px;
-webkit-text-stroke-width: 0;
-moz-osx-font-smoothing: grayscale;
}

61
resources/css/admin/_sidebar.scss vendored Normal file
View File

@ -0,0 +1,61 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
.sidebar {
background: #293042;
direction: ltr;
width: 190px;
transition: all .2s ease-in-out;
background: #fff;
border-right: 1px solid #f1f1f1;
@media screen and (max-width: 991px) {
position: fixed;
top: 0;
bottom: 0;
}
.navbar-nav {
> li.nav-item {
position: relative;
a {
padding: .6rem 1rem .6rem;
// color: rgba(58,65,111, .9);
color: #333;
transition: all .1s ease-in-out;
i {
margin-right: 7px;
}
}
&.active, &:hover {
a {
color: $main_color;
background-color: #f4f4f4;
}
}
&.active {
&:before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 100%;
z-index: 1;
background: $main_color;
}
}
}
}
}

View File

@ -0,0 +1,9 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//

13
resources/css/admin/_vue.scss vendored Normal file
View File

@ -0,0 +1,13 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-10-25 11:40:51
// @modified 2021-10-25 11:41:02
//
[v-cloak] {
display: none;
}

20
resources/css/admin/app.scss vendored Normal file
View File

@ -0,0 +1,20 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-10-25 11:41:09
//
$main_color: #fd560f;
@import 'global';
@import 'vue';
@import 'sidebar';
@import 'header';
@import 'bootstrap-extra';
@import 'iconfont';
@import 'dashboard';
@import 'subscription';

120
resources/css/admin/login.scss vendored Normal file
View File

@ -0,0 +1,120 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
$main_color: #fd560f;
.form-text.text-danger {
font-size: 0.8rem;
}
body {
background-image: url("/images/login-bg.svg");
background-size: cover;
background-position: 50%;
&.seller-register, &.password-reset {
.form-group {
margin-bottom: 1.5rem;
label {
display: none;
}
}
}
}
.card {
box-shadow: 0 20px 27px 0 rgba(0, 0, 0, .05);
border: none;
border-radius: .85rem;
overflow: hidden;
}
.card-header {
border-bottom: none;
background: #fff;
font-size: 17px;
padding-top: 40px;
h5 {
color: #344767;
font-weight: 400;
margin-bottom: 0;
}
}
button[type="submit"] {
background-image: linear-gradient(310deg, $main_color, #fdb504);
// background-image: linear-gradient(310deg, $main_color, #fdb504);
border: none;
}
.form-group {
margin-bottom: 2rem;
input.form-control {
border: 1px solid #d2d6da;
border-radius: .5rem;
font-size: .875rem;
font-weight: 400;
color: #495057;
line-height: 1.4rem;
padding: .5rem .75rem;
height: 40px;
}
}
.partition {
font-size: 12px;
&:after, &:before {
content: "";
display: inline-block;
width: 30%;
height: 1px;
position: relative;
vertical-align: middle;
}
&:after {
left: .5em;
margin-right: -50%;
background: linear-gradient(90deg,rgba(117, 117, 117, .40),rgb(117, 117, 117, .40), transparent);
}
&:before {
background: linear-gradient(90deg,transparent,rgba(117, 117, 117, .40),rgb(117, 117, 117, .40));
right: .5em;
margin-left: -50%;
}
}
.btn:not(.btn-link) {
padding: .50rem 1.5rem;
border-radius: .5rem;
transition: all .15s ease-in;
box-shadow: 0 4px 7px -1px rgba(0, 0, 0, .11), 0 2px 4px -1px rgba(0, 0, 0, .07);
&:hover {
transform: scale(1.02);
color: #fff;
}
}
.bg-gradient-dark {
background-image: linear-gradient(310deg,#141727,#3a416f);
background-color: #344767;
border: none;
color: #fff;
}

View File

@ -1,4 +0,0 @@
body {
background: #000;
color: #fff;
}

4
resources/css/app.scss vendored Normal file
View File

@ -0,0 +1,4 @@
body {
background: #f2f2f2;
// color: #fff;
}

13
resources/css/bootstrap/bootstrap.scss vendored Normal file
View File

@ -0,0 +1,13 @@
@charset "UTF-8";
//
// @copyright 2020 opencart.cn - All Rights Reserved
// @link http://www.guangdawangluo.com
// @author Sam Chen <sam.chen@opencart.cn>
// @created 2021-08-24 14:38:09
// @modified 2021-08-24 15:07:05
//
$primary: #fd560f;
@import 'node_modules/bootstrap/scss/bootstrap.scss';

View File

@ -0,0 +1,28 @@
<div class="header-wrap">
<div class="header-left">
<div class="logo">
{{-- <img src="http://dummyimage.com/200x50" class="img-fluid"> --}}
<div class="text-center"><h5 class="mb-0">beike admin</h5></div>
</div>
</div>
<div class="header-right">
<ul class="navbar navbar-right">
<li class="nav-item"><a href="{{ admin_route('home.index') }}" class="nav-link">管理首页</a></li>
<li class="nav-item"><a href="" class="nav-link">订单管理</a></li>
<li class="nav-item active"><a href="{{ admin_route('products.index') }}" class="nav-link">商品管理</a></li>
<li class="nav-item"><a href="" class="nav-link">会员管理</a></li>
<li class="nav-item"><a href="" class="nav-link">营销管理</a></li>
<li class="nav-item"><a href="" class="nav-link">系统设置</a></li>
</ul>
<ul class="navbar">
<li class="nav-item"><a href="{{ admin_route('logout.index') }}" class="nav-link">退出登录</a></li>
<li class="nav-item">
<a href="" class="nav-link">
<img src="http://dummyimage.com/100x100" class="avatar img-fluid rounded-circle me-1">
<span class="text-dark ml-2">{{ auth()->user()->name }}</span>
</a>
</li>
</ul>
</div>
</div>

View File

@ -1,16 +1,44 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
@stack('header')
</head>
<body>
@yield('content')
</body>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
<script src="//cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
{{-- <script src="{{ asset('vendor/vue/2.6.12/vue' . (config('app.debug') ? '' : '.min') . '.js') }}"></script>
<script src="{{ asset('vendor/element-ui/2.15.6/js.js') }}"></script>
<script src="{{ asset('vendor/jquery/3.6.0/jquery.min.js') }}"></script>
<script src="{{ asset('vendor/axios/0.21.1/axios.min.js') }}"></script>
<script src="{{ asset('vendor/layer/3.5.1/layer.js') }}"></script>
<script src="{{ mix('build/js/app.js') }}"></script> --}}
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
@stack('footer')
<link href="{{ mix('build/css/bootstrap.css') }}" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
@if (0)
<link rel="stylesheet" href="{{ asset('vendor/element-ui/2.15.6/css.css') }}">
@endif
<link href="{{ mix('build/css/admin/app.css') }}" rel="stylesheet">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>beike admin</title>
@stack('header')
{{-- <x-analytics /> --}}
</head>
<body class="@yield('body-class')">
<!-- <div style="height: 80px; background: white;"></div> -->
<x-admin.header />
<div class="main-content">
<aside class="sidebar navbar-expand-xs border-radius-xl">
<x-admin.sidebar />
</aside>
<div id="content">
<div class="container-fluid p-0">
<div class="page-title-box"><h4 class="page-title">@yield('title')</h4></div>
@yield('content')
</div>
</div>
</div>
@stack('footer')
</body>
</html>

View File

@ -0,0 +1,94 @@
@extends('admin.layouts.master')
@section('title', '分类管理')
@section('content')
<div id="category-app" class="card">
<div class="card-header">
所有分类
</div>
<div class="card-body">
<form action="{{ admin_route($category->id ? 'categories.update' : 'categories.store', $category) }}" method="POST">
@csrf
@method($category->id ? 'PUT' : 'POST')
<input type="hidden" name="_redirect" value="{{ request()->header('referer') ?? admin_route('categories.index') }}">
@foreach (locales() as $index => $locale)
<input type="hidden" name="descriptions[{{ $index }}][locale]" value="{{ $locale['code'] }}">
@endforeach
@foreach (locales() as $index => $locale)
<input type="text" name="descriptions[{{ $index }}][name]" placeholder="Name {{ $locale['name'] }}" value="{{ old('descriptions.'.$index.'.name', $descriptions[$locale['code']]->name ?? '') }}">
@error('descriptions.'.$index.'.name')
<x-form.error :message="$message" />
@enderror
<input type="text" name="descriptions[{{ $index }}][content]" placeholder="content {{ $locale['name'] }}" value="{{ old('descriptions.'.$index.'.content', $descriptions[$locale['code']]->content ?? '') }}">
<hr>
@endforeach
<input type="text" name="parent_id" value="{{ old('parent_id', $category->parent_id ?? 0) }}" placeholder="上级分类">
<input type="text" name="active" value="{{ old('active', $category->active ?? 1) }}" placeholder="状态">
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
@endsection
@push('footer')
<script>
new Vue({
el: '#category-app',
data() {
return {
data: [{
label: '一级 1',
children: [{
label: '二级 1-1',
children: [{
label: '三级 1-1-1'
}]
}]
}, {
label: '一级 2',
children: [{
label: '二级 2-1',
children: [{
label: '三级 2-1-1'
}]
}, {
label: '二级 2-2',
children: [{
label: '三级 2-2-1'
}]
}]
}, {
label: '一级 3',
children: [{
label: '二级 3-1',
children: [{
label: '三级 3-1-1'
}]
}, {
label: '二级 3-2',
children: [{
label: '三级 3-2-1'
}]
}]
}],
defaultProps: {
children: 'children',
label: 'label'
}
};
},
methods: {
handleNodeClick(data) {
console.log(data);
}
}
});
</script>
@endpush

View File

@ -0,0 +1,48 @@
@extends('admin.layouts.master')
@section('title', '分类管理')
@section('content')
<div id="category-app" class="card">
<div class="card-header">
所有分类
</div>
<div class="card-body">
<a href="{{ admin_route('categories.create') }}" class="btn btn-primary">创建分类</a>
<div class="mt-4" style="max-width: 800px;">
<el-tree :data="categories" default-expand-all :expand-on-click-node="false">
<div class="custom-tree-node" slot-scope="{ node, data }" style="flex:1;display:flex">
<span>@{{ data.name }}</span>
<div style="flex:1"></div>
<span class="mr-4">@{{ data.active ? '启用' : '禁用' }}</span>
<div>
<a :href="data.url_edit">Edit</a>
<a>Delete</a>
</div>
</div>
</el-tree>
</div>
</div>
</div>
@endsection
@push('footer')
<script>
new Vue({
el: '#category-app',
data: {
categories: @json($categories),
defaultProps: {
children: 'children',
label: 'name'
}
},
methods: {
handleNodeClick(data) {
console.log(data);
}
}
});
</script>
@endpush

View File

@ -1,5 +1,15 @@
@extends('admin.layouts.master')
@section('title', '后台管理')
@section('content')
<a href="{{ route('admin.products.index') }}">Products</a>
{{-- <a href="{{ route('admin.products.index') }}">Products</a> --}}
@for ($i = 0; $i < 10; $i++)
<div class="card mb-3">
<div class="card-header">订单统计</div>
<div class="card-body">
<div>11</div>
</div>
</div>
@endfor
@endsection

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<form action="{{ admin_route('login.store') }}" method="post">
@csrf
<div class="form-group">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text" id="email">邮箱</span>
</div>
<input type="text" name="email" class="form-control" value="{{ old('email') }}" placeholder="邮箱地址">
</div>
@error('email')
<x-form.error :message="$message" />
@enderror
</div>
<div class="form-group">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text" id="password">密码</span>
</div>
<input type="password" name="password" class="form-control" placeholder="密码">
</div>
@error('password')
<x-form.error :message="$message" />
@enderror
</div>
@if (session('error'))
<div class="alert alert-success">
{{ session('error') }}
</div>
@endif
<button type="submit" class="btn btn-primary btn-block mb-4">登录</button>
</form>
</body>
</html>

View File

@ -1,244 +1,250 @@
@extends('admin.layouts.master')
@push('header')
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
@endpush
@section('content')
<h2>product</h2>
<form action="{{ ($product ?? null) ? route('admin.products.update', $product) : route('admin.products.store') }}" method="POST" id="app">
<div class="card">
<div class="card-body">
<h2>product</h2>
<form action="{{ ($product ?? null) ? route('admin.products.update', $product) : route('admin.products.store') }}" method="POST" id="app">
@csrf
@method(($product ?? null) ? 'PUT' : 'POST')
<input type="hidden" name="_redirect" value="{{ old('_redirect', request()->header('referer')) }}" />
@php
if ($product ?? null) {
$descriptions = $product->descriptions->keyBy('locale');
} else {
$descriptions = [];
}
if ($product ?? null) {
$descriptions = $product->descriptions->keyBy('locale');
} else {
$descriptions = [];
}
@endphp
<div>
@foreach (locales() as $locale)
<input type="text" name="descriptions[{{ $locale['code'] }}][name]" placeholder="Name {{ $locale['name'] }}" value="{{ old('descriptions.'.$locale['code'].'.name', $descriptions[$locale['code']]->name ?? '') }}">
@endforeach
@foreach (locales() as $locale)
<input type="text" name="descriptions[{{ $locale['code'] }}][name]" placeholder="Name {{ $locale['name'] }}" value="{{ old('descriptions.'.$locale['code'].'.name', $descriptions[$locale['code']]->name ?? '') }}">
@endforeach
</div>
<div>
<input type="text" name="image" placeholder="image" value="{{ old('image', $product->image ?? '') }}">
<input type="text" name="video" placeholder="video" value="{{ old('video', $product->video ?? '') }}">
<input type="text" name="position" placeholder="position" value="{{ old('position', $product->position ?? 0) }}">
<input type="text" name="active" placeholder="active" value="{{ old('active', $product->active ?? 1) }}">
<input type="text" name="image" placeholder="image" value="{{ old('image', $product->image ?? '') }}">
<input type="text" name="video" placeholder="video" value="{{ old('video', $product->video ?? '') }}">
<input type="text" name="position" placeholder="position" value="{{ old('position', $product->position ?? 0) }}">
<input type="text" name="active" placeholder="active" value="{{ old('active', $product->active ?? 1) }}">
</div>
<div>
<h2>skus</h2>
<input type="radio" v-model="editing.isVariable" :value="false"> 单规格
<input type="radio" v-model="editing.isVariable" :value="true"> 多规格
<div v-if="editing.isVariable">
<h2>skus</h2>
<input type="radio" v-model="editing.isVariable" :value="false"> 单规格
<input type="radio" v-model="editing.isVariable" :value="true"> 多规格
<div v-if="editing.isVariable">
<div>
<div v-for="(variant, variantIndex) in source.variables">
<div>
<div v-for="(variant, variantIndex) in source.variables">
<div>
<input type="text" v-model="variant.name" placeholder="variant name">
<div v-for="(value, valueIndex) in variant.values">
<input v-model="variant.values[valueIndex].name" type="text" placeholder="variant value name">
</div>
<button type="button" @click="addVariantValue(variantIndex)">Add value</button>
</div>
</div>
<input type="text" v-model="variant.name" placeholder="variant name">
<button type="button" @click="addVariant">Add variant</button>
<div v-for="(value, valueIndex) in variant.values">
<input v-model="variant.values[valueIndex].name" type="text" placeholder="variant value name">
</div>
<button type="button" @click="addVariantValue(variantIndex)">Add value</button>
</div>
</div>
<div v-if="form.skus.length">
<input v-if="form.skus.length" type="hidden" name="variables" :value="JSON.stringify(form.variables)">
<table>
<thead>
<th v-for="(variant, index) in form.variables" :key="'pv-header-'+index">
@{{ variant.name || 'No name' }}
</th>
<th>image</th>
<th>model</th>
<th>sku</th>
<th>price</th>
<th>orgin price</th>
<th>cost price</th>
<th>quantity</th>
</thead>
<tbody>
<tr v-for="(sku, skuIndex) in form.skus">
<template v-for="(variantValueIndex, j) in sku.variants">
<td v-if="skuIndex % variantValueRepetitions[j] == 0" :key="'pvv'+skuIndex+'-'+j" :rowspan="variantValueRepetitions[j]">
<span>@{{ form.variables[j].values[variantValueIndex].name || 'No name' }}</span>
</td>
</template>
<td>
<input type="text" v-model="sku.image" :name="'skus[' + skuIndex + '][image]'" placeholder="image">
<input type="hidden" :name="'skus[' + skuIndex + '][is_default]'" :value="skuIndex == 0 ? 1 : 0">
<input v-for="(variantValueIndex, j) in sku.variants" type="hidden" :name="'skus[' + skuIndex + '][variants][' + j + ']'" :value="variantValueIndex">
</td>
<td><input type="text" v-model="sku.model" :name="'skus[' + skuIndex + '][model]'" placeholder="model"></td>
<td><input type="text" v-model="sku.sku" :name="'skus[' + skuIndex + '][sku]'" placeholder="sku"></td>
<td><input type="text" v-model="sku.price" :name="'skus[' + skuIndex + '][price]'" placeholder="price"></td>
<td><input type="text" v-model="sku.origin_price" :name="'skus[' + skuIndex + '][origin_price]'" placeholder="origin_price"></td>
<td><input type="text" v-model="sku.cost_price" :name="'skus[' + skuIndex + '][cost_price]'" placeholder="cost_price"></td>
<td><input type="text" v-model="sku.quantity" :name="'skus[' + skuIndex + '][quantity]'" placeholder="quantity"></td>
</tr>
</tbody>
</table>
</div>
<button type="button" @click="addVariant">Add variant</button>
</div>
<div v-if="!editing.isVariable">
<div>
<input type="text" name="skus[0][image]" placeholder="image" value="{{ old('skus.0.image', $product->skus[0]->image ?? '') }}">
<input type="text" name="skus[0][model]" placeholder="model" value="{{ old('skus.0.model', $product->skus[0]->model ?? '') }}">
<input type="text" name="skus[0][sku]" placeholder="sku" value="{{ old('skus.0.sku', $product->skus[0]->sku ?? '') }}">
<input type="text" name="skus[0][price]" placeholder="price" value="{{ old('skus.0.price', $product->skus[0]->price ?? '') }}">
<input type="text" name="skus[0][origin_price]" placeholder="origin_price" value="{{ old('skus.0.origin_price', $product->skus[0]->origin_price ?? '') }}">
<input type="text" name="skus[0][cost_price]" placeholder="cost_price" value="{{ old('skus.0.cost_price', $product->skus[0]->cost_price ?? '') }}">
<input type="text" name="skus[0][quantity]" placeholder="quantity" value="{{ old('skus.0.quantity', $product->skus[0]->quantity ?? '') }}">
<input type="hidden" name="skus[0][variants]" placeholder="variants" value="">
<input type="hidden" name="skus[0][position]" placeholder="position" value="0">
<input type="hidden" name="skus[0][is_default]" placeholder="is_default" value="1">
</div>
<div v-if="form.skus.length">
<input v-if="form.skus.length" type="hidden" name="variables" :value="JSON.stringify(form.variables)">
<table>
<thead>
<th v-for="(variant, index) in form.variables" :key="'pv-header-'+index">
@{{ variant.name || 'No name' }}
</th>
<th>image</th>
<th>model</th>
<th>sku</th>
<th>price</th>
<th>orgin price</th>
<th>cost price</th>
<th>quantity</th>
</thead>
<tbody>
<tr v-for="(sku, skuIndex) in form.skus">
<template v-for="(variantValueIndex, j) in sku.variants">
<td v-if="skuIndex % variantValueRepetitions[j] == 0" :key="'pvv'+skuIndex+'-'+j" :rowspan="variantValueRepetitions[j]">
<span>@{{ form.variables[j].values[variantValueIndex].name || 'No name' }}</span>
</td>
</template>
<td>
<input type="text" v-model="sku.image" :name="'skus[' + skuIndex + '][image]'" placeholder="image">
<input type="hidden" :name="'skus[' + skuIndex + '][is_default]'" :value="skuIndex == 0 ? 1 : 0">
<input v-for="(variantValueIndex, j) in sku.variants" type="hidden" :name="'skus[' + skuIndex + '][variants][' + j + ']'" :value="variantValueIndex">
</td>
<td><input type="text" v-model="sku.model" :name="'skus[' + skuIndex + '][model]'" placeholder="model"></td>
<td><input type="text" v-model="sku.sku" :name="'skus[' + skuIndex + '][sku]'" placeholder="sku"></td>
<td><input type="text" v-model="sku.price" :name="'skus[' + skuIndex + '][price]'" placeholder="price"></td>
<td><input type="text" v-model="sku.origin_price" :name="'skus[' + skuIndex + '][origin_price]'" placeholder="origin_price"></td>
<td><input type="text" v-model="sku.cost_price" :name="'skus[' + skuIndex + '][cost_price]'" placeholder="cost_price"></td>
<td><input type="text" v-model="sku.quantity" :name="'skus[' + skuIndex + '][quantity]'" placeholder="quantity"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!editing.isVariable">
<div>
<input type="text" name="skus[0][image]" placeholder="image" value="{{ old('skus.0.image', $product->skus[0]->image ?? '') }}">
<input type="text" name="skus[0][model]" placeholder="model" value="{{ old('skus.0.model', $product->skus[0]->model ?? '') }}">
<input type="text" name="skus[0][sku]" placeholder="sku" value="{{ old('skus.0.sku', $product->skus[0]->sku ?? '') }}">
<input type="text" name="skus[0][price]" placeholder="price" value="{{ old('skus.0.price', $product->skus[0]->price ?? '') }}">
<input type="text" name="skus[0][origin_price]" placeholder="origin_price" value="{{ old('skus.0.origin_price', $product->skus[0]->origin_price ?? '') }}">
<input type="text" name="skus[0][cost_price]" placeholder="cost_price" value="{{ old('skus.0.cost_price', $product->skus[0]->cost_price ?? '') }}">
<input type="text" name="skus[0][quantity]" placeholder="quantity" value="{{ old('skus.0.quantity', $product->skus[0]->quantity ?? '') }}">
<input type="hidden" name="skus[0][variants]" placeholder="variants" value="">
<input type="hidden" name="skus[0][position]" placeholder="position" value="0">
<input type="hidden" name="skus[0][is_default]" placeholder="is_default" value="1">
</div>
</div>
</div>
<button type="submit">Save</button>
</form>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
@endsection
@push('footer')
<script>
var app = new Vue({
el: '#app',
data: {
form: {
variables: @json($product->variables_decoded ?? []),
skus: @json($product->skus ?? []),
},
source: {
variables: @json($product->variables_decoded ?? []),
},
editing: {
isVariable: @json(($product->variables ?? null) != null),
}
},
computed: {
// variant value 重复次数
variantValueRepetitions() {
var repeats = [];
var repeat = 1;
for (var index = this.form.variables.length - 2; index >= 0; index--) {
repeat *= this.form.variables[index + 1].values.length;
repeats[index] = repeat;
}
// 最后一组只重复1次
repeats.push(1);
return repeats;
},
},
watch: {
'source.variables': {
deep: true,
handler: function () {
// 原始规格数据变动,过滤有效规格并同步至 form.variables
let variants = [];
const sourceVariants = JSON.parse(JSON.stringify(this.source.variables));
for (var i = 0; i < sourceVariants.length; i++) {
const sourceVariant = sourceVariants[i];
// 排除掉没有规格值的
if (sourceVariant.values.length > 0) {
variants.push(sourceVariant);
}
}
this.form.variables = variants;
this.remakeSkus();
}
}
},
methods: {
addVariant() {
this.source.variables.push({name: '', values: []});
},
addVariantValue(variantIndex) {
this.source.variables[variantIndex].values.push({name: '', image: ''});
},
remakeSkus() {
const combos = makeVariableIndexes();
if (combos.length < 1) {
this.form.skus = [];
return;
}
// 找出已存在的组合
const productVariantCombos = this.form.skus.map(v => v.variants.join()); // ['0,0,0', '0,0,1']
let skus = [];
for (var i = 0; i < combos.length; i++) {
const combo = combos[i]; // 0,0,0
const index = productVariantCombos.indexOf(combo.join());
if (index > -1) {
skus.push(this.form.skus[index]);
} else {
skus.push({
product_sku_id: 0,
position: i,
variants: combo,
image: '',
model: '',
sku: '',
price: null,
quantity: null,
is_default: i == 0,
});
}
}
// 第一个子商品用主商品的值
skus[0].model = this.form.model;
skus[0].sku = this.form.sku;
skus[0].price = this.form.price;
skus[0].quantity = this.form.quantity;
skus[0].status = this.form.status;
this.form.skus = skus;
},
}
});
function makeVariableIndexes() {
// 每组值重复次数
var repeats = app.variantValueRepetitions;
var results = [];
if (app.form.variables.length < 1) {
return results;
}
for (let i = 0; i < repeats[0] * app.form.variables[0].values.length; i++) {
results.push([]);
}
for (let xIndex = 0; xIndex < repeats.length; xIndex++) { // 0 - 3
let repeat = 0;
let itemIndex = 0;
for (let yIndex = 0; yIndex < results.length; yIndex++) { // 0 - 36
results[yIndex].push(itemIndex);
repeat++;
if (repeat >= repeats[xIndex]) {
repeat = 0;
itemIndex++;
if (itemIndex >= app.form.variables[xIndex].values.length) {
itemIndex = 0;
}
}
}
}
return results;
<script>
var app = new Vue({
el: '#app',
data: {
form: {
variables: @json($product->variables_decoded ?? []),
skus: @json($product->skus ?? []),
},
source: {
variables: @json($product->variables_decoded ?? []),
},
editing: {
isVariable: @json(($product->variables ?? null) != null),
}
</script>
},
computed: {
// variant value 重复次数
variantValueRepetitions() {
var repeats = [];
var repeat = 1;
for (var index = this.form.variables.length - 2; index >= 0; index--) {
repeat *= this.form.variables[index + 1].values.length;
repeats[index] = repeat;
}
// 最后一组只重复1次
repeats.push(1);
return repeats;
},
},
watch: {
'source.variables': {
deep: true,
handler: function () {
// 原始规格数据变动,过滤有效规格并同步至 form.variables
let variants = [];
const sourceVariants = JSON.parse(JSON.stringify(this.source.variables));
for (var i = 0; i < sourceVariants.length; i++) {
const sourceVariant = sourceVariants[i];
// 排除掉没有规格值的
if (sourceVariant.values.length > 0) {
variants.push(sourceVariant);
}
}
this.form.variables = variants;
this.remakeSkus();
}
}
},
methods: {
addVariant() {
this.source.variables.push({name: '', values: []});
},
addVariantValue(variantIndex) {
this.source.variables[variantIndex].values.push({name: '', image: ''});
},
remakeSkus() {
const combos = makeVariableIndexes();
if (combos.length < 1) {
this.form.skus = [];
return;
}
// 找出已存在的组合
const productVariantCombos = this.form.skus.map(v => v.variants.join()); // ['0,0,0', '0,0,1']
let skus = [];
for (var i = 0; i < combos.length; i++) {
const combo = combos[i]; // 0,0,0
const index = productVariantCombos.indexOf(combo.join());
if (index > -1) {
skus.push(this.form.skus[index]);
} else {
skus.push({
product_sku_id: 0,
position: i,
variants: combo,
image: '',
model: '',
sku: '',
price: null,
quantity: null,
is_default: i == 0,
});
}
}
// 第一个子商品用主商品的值
skus[0].model = this.form.model;
skus[0].sku = this.form.sku;
skus[0].price = this.form.price;
skus[0].quantity = this.form.quantity;
skus[0].status = this.form.status;
this.form.skus = skus;
},
}
});
function makeVariableIndexes() {
// 每组值重复次数
var repeats = app.variantValueRepetitions;
var results = [];
if (app.form.variables.length < 1) {
return results;
}
for (let i = 0; i < repeats[0] * app.form.variables[0].values.length; i++) {
results.push([]);
}
for (let xIndex = 0; xIndex < repeats.length; xIndex++) { // 0 - 3
let repeat = 0;
let itemIndex = 0;
for (let yIndex = 0; yIndex < results.length; yIndex++) { // 0 - 36
results[yIndex].push(itemIndex);
repeat++;
if (repeat >= repeats[xIndex]) {
repeat = 0;
itemIndex++;
if (itemIndex >= app.form.variables[xIndex].values.length) {
itemIndex = 0;
}
}
}
}
return results;
}
</script>
@endpush

View File

@ -1,21 +1,105 @@
@extends('admin.layouts.master')
@section('title', '商品管理')
@push('header')
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.27.2/axios.min.js"></script>
@endpush
@section('content')
<a href="{{ route('admin.products.create') }}">Create</a>
<div class="card">
<div class="card-body">
<x-filter :url="route('admin.products.index')" />
</div>
</div>
<x-filter :url="route('admin.products.index')" />
<div class="card mt-4" id="product-app">
<div class="card-header">
<a href="{{ route('admin.products.create') }}" class="btn btn-primary">Create</a>
</div>
<div class="card-body">
<table v-if="items.length" class="table">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>图片</th>
<th>商品名称</th>
<th>价格</th>
<th>创建时间</th>
<th>上架</th>
<th>操作</th>
</tr>
</thead>
<tr v-for="(item, index) in items" :key="item.id">
<td>
<input type="checkbox" :value="item.id" v-model="selected" />
</td>
<td>@{{ item.id }}</td>
<td><img :src="item.image" alt="" srcset=""></td>
<td>@{{ item.name || '无名称' }}</td>
<td>@{{ item.price_formatted }}</td>
<td>@{{ item.created_at }}</td>
<td>@{{ item.active ? '上架' : '下架' }}</td>
<td>
<a :href="item.url_edit">编辑</a>
<template>
<a v-if="item.deleted_at == ''" href="javascript:void(0)" @click.prevent="deleteProduct(index)">删除</a>
<a v-else href="javascript:void(0)" @click.prevent="restoreProduct(index)">恢复</a>
</template>
</td>
</tr>
</table>
{{ $products->links() }}
<p v-if="items.length < 1">无商品</p>
</div>
</div>
<table>
@foreach ($products as $product)
<tr>
<td>{{ $product->id }}</td>
<td>{{ $product->description->name ?? '--' }}</td>
<td>{{ $product->variables ? '多规格' : '单规格' }}</td>
<td>
<a href="{{ route('admin.products.edit', $product) }}">编辑</a>
</td>
</tr>
@endforeach
</table>
@endsection
@push('footer')
<script>
new Vue({
el: '#product-app',
data: {
items: @json($products->items()),
selected: [],
},
methods: {
deleteProduct: function (index) {
const product = this.items[index];
this.$confirm('确认要删除选中的商品吗?', '删除商品', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
type: 'warning'
}).then(() => {
axios.delete('products/' + product.id).then(response => {
location.reload();
}).catch(error => {
// this.$message.error(error.response.data.message);
});
});
},
restoreProduct: function (index) {
const product = this.items[index];
this.$confirm('确认要恢复选中的商品吗?', '恢复商品', {
type: 'warning'
}).then(() => {
axios.put('products/restore', {
id: product.id
}).then(response => {
location.reload();
}).catch(error => {
// this.$message.error(error.response.data.message);
});
});
}
}
});
</script>
@endpush

View File

@ -0,0 +1,25 @@
<div class="header-wrap">
<div class="header-left">
<div class="logo">
{{-- <img src="http://dummyimage.com/200x50" class="img-fluid"> --}}
<div class="text-center"><h5 class="mb-0">beike admin</h5></div>
</div>
</div>
<div class="header-right">
<ul class="navbar navbar-right">
@foreach ($links as $link)
<li class="nav-item {{ $link['active'] ? 'active' : '' }}"><a href="{{ $link['url'] }}" class="nav-link">{{ $link['title'] }}</a></li>
@endforeach
</ul>
<ul class="navbar">
<li class="nav-item"><a href="{{ admin_route('logout.index') }}" class="nav-link">退出登录</a></li>
<li class="nav-item">
<a href="" class="nav-link">
<img src="http://dummyimage.com/100x100" class="avatar img-fluid rounded-circle me-1">
<span class="text-dark ml-2">{{ auth()->user()->name }}</span>
</a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,9 @@
<div class="">
<ul class="list-unstyled navbar-nav">
@foreach ($links as $link)
<li class="nav-item {{ $link['active'] ? 'active' : '' }}">
<a class="nav-link" href="{{ $link['url'] }}"><i class="iconfont">&#xe65c;</i> {{ $link['title'] }}</a>
</li>
@endforeach
</ul>
</div>

View File

@ -1,6 +1,9 @@
<div id="filter-app">
<input type="text" v-model="keyword">
<button type="button" @click="search">筛选</button>
<form class="form-inline">
<input type="text" v-model="keyword" class="form-control mr-2">
<button type="button" @click="search" class="btn btn-primary">筛选</button>
</form>
</div>
@push('footer')

View File

@ -0,0 +1,3 @@
<div class="form-text text-danger">
{{ $message }}
</div>

View File

@ -5,7 +5,20 @@ use Illuminate\Support\Facades\Route;
Route::prefix('admin')
->name('admin.')
->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\HomeController::class, 'index'])->name('home.index');
Route::get('login', [\App\Http\Controllers\Admin\LoginController::class, 'show'])->name('login.show');
Route::post('login', [\App\Http\Controllers\Admin\LoginController::class, 'store'])->name('login.store');
Route::resource('products', \App\Http\Controllers\Admin\ProductsController::class);
Route::middleware('auth:'.\App\Models\AdminUser::AUTH_GUARD)
->group(function () {
Route::get('/', [\App\Http\Controllers\Admin\HomeController::class, 'index'])->name('home.index');
Route::Resource('categories', \App\Http\Controllers\Admin\CategoriesController::class);
Route::put('products/restore', [\App\Http\Controllers\Admin\ProductsController::class, 'restore']);
Route::resource('products', \App\Http\Controllers\Admin\ProductsController::class);
Route::get('logout', [\App\Http\Controllers\Admin\LogoutController::class, 'index'])->name('logout.index');
});
});

View File

@ -9,5 +9,7 @@ Route::prefix('/')
Route::get('carts', [App\Http\Controllers\Shop\CartsController::class, 'store'])->name('carts.store');
Route::get('categories/{category}', [App\Http\Controllers\Shop\CategoriesController::class, 'show'])->name('categories.show');
Route::get('products/{product}', [App\Http\Controllers\Shop\ProductsController::class, 'show'])->name('products.show');
});

View File

@ -1 +1,3 @@
<h2>child</h2>
<div>
<a href="{{ shop_route('home.index') }}">Home</a>
</div>

View File

@ -0,0 +1,11 @@
@extends('layout.master')
@section('content')
@foreach ($products as $product)
<a href="{{ shop_route('products.show', $product) }}">
<img src="{{ thumbnail($product->image) }}" alt="{{ $product->description->name }}">
{{ $product->description->name }}
</a>
@endforeach
@endsection

View File

@ -1,14 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<h1>welcome acme</h1>
@include('header')
</body>
</html>
@extends('layout.master')
@section('content')
<h1>Acme theme - Home</h1>
<h2>Categories</h2>
@foreach ($categories as $category)
<a href="{{ shop_route('categories.show', $category) }}">{{ $category->description->name }}</a>
@endforeach
@endsection

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
@stack('header')
</head>
<body>
@include('header')
@yield('content')
@stack('footer')
</body>
</html>

View File

@ -1,13 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<p>{{ $product->description->name }}</p>
@extends('layout.master')
@section('content')
<p>{{ $product->description->name }}</p>
<h2>SKUs</h2>
<table>
@ -32,5 +26,4 @@
@endforeach
</tbody>
</table>
</body>
</html>
@endsection

15
webpack.mix.js vendored
View File

@ -11,12 +11,17 @@ const mix = require('laravel-mix');
|
*/
mix.js('resources/js/app.js', 'public/build/js')
.postCss('resources/css/app.css', 'public/build/css', [
//
]);
// mix.js('resources/js/app.js', 'public/build/js')
// .postCss('resources/css/app.css', 'public/build/css', [
// //
// ]);
mix.sass('resources/css/app.scss', 'public/build/css');
mix.sass('resources/css/bootstrap/bootstrap.scss', 'public/build/css/bootstrap.css');
mix.sass('resources/css/admin/app.scss', 'public/build/css/admin');
if (mix.inProduction()) {
mix.version();
}
}