laravel10 dcat admin2 上传文件到oss

AI摘要
本文是一篇技术知识分享,详细介绍了在 Laravel 项目中集成阿里云 OSS V2 SDK 的完整流程。核心步骤包括:安装 SDK、配置环境变量与文件系统、编写 OSS 服务类、创建 Flysystem 适配器、注册服务提供者,并最终实现在 Dcat Admin 及 Laravel Storage 中的无缝使用。
[TOC]

1. 安装 OSS SDK V2

安装扩展包

composer require alibabacloud/oss-v2

如果连接超时,可配置镜像源

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

清除缓存

composer clear-cache

2. 设置配置文件

设置配置文件.env

# 阿里云 OSS 配置
OSS_ACCESS_KEY_ID=your_access_key_id
OSS_ACCESS_KEY_SECRET=your_access_key_secret
OSS_BUCKET=your_bucket_name
OSS_REGION=cn-hangzhou
OSS_ENDPOINT=
OSS_DOMAIN=https://your-bucket.oss-cn-hangzhou.aliyuncs.com
OSS_IS_CNAME=false
OSS_PREFIX=
OSS_USE_INTERNAL=false

3. 配置 config/filesystems.php

'disks' => [
    // ... 其他配置

    'oss' => [
        'driver'            => 'oss',
        'access_key_id'     => env('OSS_ACCESS_KEY_ID'),
        'access_key_secret' => env('OSS_ACCESS_KEY_SECRET'),
        'bucket'            => env('OSS_BUCKET'),
        'region'            => env('OSS_REGION', 'cn-hangzhou'),
        'endpoint'          => env('OSS_ENDPOINT', ''),
        'is_cname'          => env('OSS_IS_CNAME', false),
        'domain'            => env('OSS_DOMAIN'),
        'prefix'            => env('OSS_PREFIX', ''),
        'use_internal'      => env('OSS_USE_INTERNAL', false), // 是否使用内网
        'url_expire'        => env('OSS_URL_EXPIRE', 3600), // 签名URL过期时间
    ]
],

4. 配置 OSS 服务类

<?php

namespace App\Services\Tool;

use AlibabaCloud\Oss\V2 as Oss;
use Illuminate\Http\UploadedFile;

class OssService
{
    private Oss\Client $client;
    private string $bucket;
    private string $region;
    private ?string $endpoint;
    private ?string $domain;
    private bool $isCname;

    public function __construct()
    {
        $credentialsProvider = new Oss\Credentials\EnvironmentVariableCredentialsProvider();
        // 配置客户端
        $cfg = Oss\Config::loadDefault();
        $cfg->setCredentialsProvider(credentialsProvider: $credentialsProvider);
        $cfg->setRegion(region: env('OSS_REGION', 'cn-hangzhou'));
        if ($endpoint = env('OSS_ENDPOINT')) {
            $cfg->setEndpoint(endpoint: $endpoint);
        }
        // 设置是否使用内网
        if (env('OSS_USE_INTERNAL', false)) {
            $cfg->setUseInternalEndpoint(true);
        }
        // 设置是否使用 CNAME
        if (env('OSS_IS_CNAME', false)) {
            $cfg->setUseCname(true);
        }
        // 初始化客户端
        $this->client = new Oss\Client($cfg);
        $this->bucket = env('OSS_BUCKET');
        $this->region = env('OSS_REGION', 'cn-hangzhou');
        $this->endpoint = env('OSS_ENDPOINT');
        $this->domain = env('OSS_DOMAIN');
        $this->isCname = env('OSS_IS_CNAME', false);
    }

    /**
     * 上传文件
     */
    public function uploadFile($file, string $path = null, bool $useOriginalName = false): array
    {
        try {
            if ($file instanceof UploadedFile) {
                $extension = $file->getClientOriginalExtension();
                if ($useOriginalName) {
                    $filename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
                    $filename = $filename . '_' . time() . '.' . $extension;
                } else {
                    $filename = uniqid() . '_' . time() . '.' . $extension;
                }
            } else {
                $filename = basename($file);
            }

            $objectKey = trim($path ?? date('Y/m/d'), '/') . '/' . $filename;
            $request = new Oss\Models\PutObjectRequest(
                bucket: $this->bucket,
                key: $objectKey
            );

            if ($file instanceof UploadedFile) {
                $request->body = Oss\Utils::streamFor(fopen($file->getPathname(), 'r'));
                $request->contentType = $file->getMimeType();
            } else {
                if (is_string($file) && file_exists($file)) {
                    $request->body = Oss\Utils::streamFor(fopen($file, 'r'));
                    $request->contentType = mime_content_type($file);
                } else {
                    $request->body = Oss\Utils::streamFor($file);
                }
            }

            $result = $this->client->putObject($request);

            if ($result->statusCode === 200) {
                return [
                    'success' => true,
                    'path' => $objectKey,
                    'url' => $this->getFileUrl($objectKey),
                    'public_url' => $this->getPublicUrl($objectKey),
                    'etag' => trim($result->etag, '"'),
                ];
            }

            return [
                'success' => false,
                'message' => '文件上传失败'
            ];

        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    /**
     * 批量上传
     */
    public function uploadFiles(array $files, string $path = null): array
    {
        $results = [];
        foreach ($files as $file) {
            $results[] = $this->uploadFile($file, $path);
        }
        return $results;
    }

    /**
     * 删除文件
     */
    public function deleteFile(string $objectKey): array
    {
        try {
            $request = new Oss\Models\DeleteObjectRequest(
                bucket: $this->bucket,
                key: $objectKey
            );

            $result = $this->client->deleteObject($request);

            return [
                'success' => true,
                'statusCode' => $result->statusCode
            ];

        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    /**
     * 批量删除文件
     */
    public function deleteFiles(array $objectKeys): array
    {
        try {
            $objects = [];
            foreach ($objectKeys as $key) {
                $obj = new Oss\Models\DeleteObject();
                $obj->key = $key;
                $objects[] = $obj;
            }

            $delete = new Oss\Models\Delete();
            $delete->objects = $objects;

            $request = new Oss\Models\DeleteMultipleObjectsRequest(
                bucket: $this->bucket,
                delete: $delete
            );

            $result = $this->client->deleteMultipleObjects($request);

            return [
                'success' => true,
                'deleted' => $result->deletedObjects ?? []
            ];

        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    /**
     * 检查文件是否存在
     */
    public function fileExists(string $objectKey): bool
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $objectKey
            );
            $this->client->headObject($request);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 获取文件信息
     */
    public function getFileInfo(string $objectKey): ?array
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $objectKey
            );
            $result = $this->client->headObject($request);

            return [
                'size' => $result->contentLength,
                'type' => $result->contentType,
                'etag' => trim($result->etag, '"'),
                'last_modified' => $result->lastModified?->format('Y-m-d H:i:s'),
            ];
        } catch (\Exception $e) {
            return null;
        }
    }

    /**
     * 复制文件
     */
    public function copyFile(string $sourceKey, string $destinationKey): array
    {
        try {
            $request = new Oss\Models\CopyObjectRequest(
                bucket: $this->bucket,
                key: $destinationKey
            );
            $request->sourceKey = $sourceKey;

            $result = $this->client->copyObject($request);

            return [
                'success' => true,
                'etag' => trim($result->copyObjectResult->etag ?? '', '"')
            ];
        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }

    /**
     * 获取文件签名URL(带过期时间)
     */
    public function getFileUrl(string $objectKey, int $expires = null): string
    {
        try {
            $request = new Oss\Models\GetObjectRequest(
                bucket: $this->bucket,
                key: $objectKey
            );

            $expires = $expires ?? (int)env('OSS_URL_EXPIRE', 3600);
            return $this->client->signUrl($request, $expires);
        } catch (\Exception $e) {
            return '';
        }
    }

    /**
     * 获取公共访问URL(无签名)
     */
    public function getPublicUrl(string $objectKey): string
    {
        if ($this->domain) {
            return rtrim($this->domain, '/') . '/' . ltrim($objectKey, '/');
        }
        if ($this->isCname && $this->endpoint) {
            return 'https://' . rtrim($this->endpoint, '/') . '/' . ltrim($objectKey, '/');
        }
        return "https://{$this->bucket}.oss-{$this->region}.aliyuncs.com/" . ltrim($objectKey, '/');
    }

    /**
     * 列出文件
     */
    public function listFiles(string $prefix = '', int $maxKeys = 100): array
    {
        try {
            $request = new Oss\Models\ListObjectsV2Request(
                bucket: $this->bucket
            );
            $request->prefix = $prefix;
            $request->maxKeys = $maxKeys;

            $result = $this->client->listObjectsV2($request);

            $files = [];
            foreach ($result->contents ?? [] as $content) {
                $files[] = [
                    'key' => $content->key,
                    'size' => $content->size,
                    'last_modified' => $content->lastModified?->format('Y-m-d H:i:s'),
                    'etag' => trim($content->etag ?? '', '"'),
                    'url' => $this->getPublicUrl($content->key),
                ];
            }

            return [
                'success' => true,
                'files' => $files,
                'count' => count($files),
            ];
        } catch (\Exception $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }
}

5. 创建 OSS Adapter(Laravel Filesystem 集成)

app/Support/OssAdapter.php:

<?php

namespace App\Support;

use League\Flysystem\Config;
use League\Flysystem\FileAttributes;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToCopyFile;
use League\Flysystem\UnableToMoveFile;
use AlibabaCloud\Oss\V2 as Oss;

class OssAdapter implements FilesystemAdapter
{
    protected Oss\Client $client;
    protected string $bucket;
    protected array $config;

    public function __construct(Oss\Client $client, string $bucket, array $config = [])
    {
        $this->client = $client;
        $this->bucket = $bucket;
        $this->config = $config;
    }

    public function fileExists(string $path): bool
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $this->client->headObject($request);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    public function directoryExists(string $path): bool
    {
        return true;
    }

    public function write(string $path, string $contents, Config $config): void
    {
        try {
            $request = new Oss\Models\PutObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $request->body = $contents;

            if ($mimeType = $config->get('mimetype')) {
                $request->contentType = $mimeType;
            }

            $this->client->putObject($request);
        } catch (\Exception $e) {
            throw UnableToWriteFile::atLocation($path, $e->getMessage());
        }
    }

    public function writeStream(string $path, $contents, Config $config): void
    {
        try {
            $request = new Oss\Models\PutObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $request->body = Oss\Utils::streamFor($contents);

            if ($mimeType = $config->get('mimetype')) {
                $request->contentType = $mimeType;
            }

            $this->client->putObject($request);
        } catch (\Exception $e) {
            throw UnableToWriteFile::atLocation($path, $e->getMessage());
        }
    }

    public function read(string $path): string
    {
        try {
            $request = new Oss\Models\GetObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $result = $this->client->getObject($request);
            return $result->body->getContents();
        } catch (\Exception $e) {
            throw UnableToReadFile::fromLocation($path, $e->getMessage());
        }
    }

    public function readStream(string $path)
    {
        try {
            $request = new Oss\Models\GetObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $result = $this->client->getObject($request);
            return $result->body->detach();
        } catch (\Exception $e) {
            throw UnableToReadFile::fromLocation($path, $e->getMessage());
        }
    }

    public function delete(string $path): void
    {
        try {
            $request = new Oss\Models\DeleteObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $this->client->deleteObject($request);
        } catch (\Exception $e) {
            throw UnableToDeleteFile::atLocation($path, $e->getMessage());
        }
    }

    public function deleteDirectory(string $path): void
    {
        $prefix = rtrim($this->prefixPath($path), '/') . '/';

        try {
            $continuationToken = null;
            do {
                $request = new Oss\Models\ListObjectsV2Request(
                    bucket: $this->bucket
                );
                $request->prefix = $prefix;
                $request->maxKeys = 1000;

                if ($continuationToken) {
                    $request->continuationToken = $continuationToken;
                }

                $result = $this->client->listObjectsV2($request);

                if (!empty($result->contents)) {
                    $objects = [];
                    foreach ($result->contents as $item) {
                        $obj = new Oss\Models\DeleteObject();
                        $obj->key = $item->key;
                        $objects[] = $obj;
                    }

                    $delete = new Oss\Models\Delete();
                    $delete->objects = $objects;

                    $deleteRequest = new Oss\Models\DeleteMultipleObjectsRequest(
                        bucket: $this->bucket,
                        delete: $delete
                    );
                    $this->client->deleteMultipleObjects($deleteRequest);
                }

                $continuationToken = $result->nextContinuationToken ?? null;
            } while ($continuationToken);
        } catch (\Exception $e) {
            // 忽略错误
        }
    }

    public function createDirectory(string $path, Config $config): void
    {
    }

    public function setVisibility(string $path, string $visibility): void
    {
    }

    public function visibility(string $path): FileAttributes
    {
        return new FileAttributes($path, null, 'public');
    }

    public function mimeType(string $path): FileAttributes
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $result = $this->client->headObject($request);
            return new FileAttributes($path, null, null, null, $result->contentType);
        } catch (\Exception $e) {
            return new FileAttributes($path);
        }
    }

    public function lastModified(string $path): FileAttributes
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $result = $this->client->headObject($request);
            $timestamp = $result->lastModified ? $result->lastModified->getTimestamp() : null;
            return new FileAttributes($path, null, null, $timestamp);
        } catch (\Exception $e) {
            return new FileAttributes($path);
        }
    }

    public function fileSize(string $path): FileAttributes
    {
        try {
            $request = new Oss\Models\HeadObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($path)
            );
            $result = $this->client->headObject($request);
            return new FileAttributes($path, $result->contentLength);
        } catch (\Exception $e) {
            return new FileAttributes($path);
        }
    }

    public function listContents(string $path, bool $deep): iterable
    {
        $prefix = rtrim($this->prefixPath($path), '/');
        if ($prefix) {
            $prefix .= '/';
        }

        try {
            $continuationToken = null;
            do {
                $request = new Oss\Models\ListObjectsV2Request(
                    bucket: $this->bucket
                );
                $request->prefix = $prefix;
                $request->maxKeys = 1000;

                if ($continuationToken) {
                    $request->continuationToken = $continuationToken;
                }

                if (!$deep) {
                    $request->delimiter = '/';
                }

                $result = $this->client->listObjectsV2($request);

                if (!empty($result->contents)) {
                    foreach ($result->contents as $content) {
                        yield new FileAttributes(
                            $this->removePrefix($content->key),
                            $content->size,
                            null,
                            $content->lastModified ? $content->lastModified->getTimestamp() : null
                        );
                    }
                }

                $continuationToken = $result->nextContinuationToken ?? null;
            } while ($continuationToken);
        } catch (\Exception $e) {
            return;
        }
    }

    public function move(string $source, string $destination, Config $config): void
    {
        try {
            $this->copy($source, $destination, $config);
            $this->delete($source);
        } catch (\Exception $e) {
            throw UnableToMoveFile::fromLocationTo($source, $destination, $e);
        }
    }

    public function copy(string $source, string $destination, Config $config): void
    {
        try {
            $request = new Oss\Models\CopyObjectRequest(
                bucket: $this->bucket,
                key: $this->prefixPath($destination)
            );
            $request->sourceKey = $this->prefixPath($source);

            $this->client->copyObject($request);
        } catch (\Exception $e) {
            throw UnableToCopyFile::fromLocationTo($source, $destination, $e);
        }
    }

    public function getUrl(string $path): string
    {
        $path = $this->prefixPath($path);
        if ($domain = $this->config['domain'] ?? null) {
            return rtrim($domain, '/') . '/' . ltrim($path, '/');
        }

        $region = $this->config['region'] ?? 'cn-hangzhou';
        if (($this->config['is_cname'] ?? false) && !empty($this->config['endpoint'])) {
            return 'https://' . rtrim($this->config['endpoint'], '/') . '/' . ltrim($path, '/');
        }

        if ($this->config['use_internal'] ?? false) {
            return "https://{$this->bucket}.oss-{$region}-internal.aliyuncs.com/{$path}";
        }
        return "https://{$this->bucket}.oss-{$region}.aliyuncs.com/{$path}";
    }

    protected function prefixPath(string $path): string
    {
        $prefix = $this->config['prefix'] ?? '';
        return trim($prefix . '/' . ltrim($path, '/'), '/');
    }

    protected function removePrefix(string $path): string
    {
        $prefix = $this->config['prefix'] ?? '';
        if ($prefix && strpos($path, $prefix) === 0) {
            return substr($path, strlen($prefix) + 1);
        }
        return $path;
    }
}

6. 创建 Service Provider

创建app/Support/FilesystemWrapper.php

<?php

namespace App\Support;

use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemAdapter;

/**
 * Flysystem V3 兼容包装类
 * 提供 Flysystem V2 API 兼容性(用于 Dcat Admin)
 */
class FilesystemWrapper extends Filesystem
{
    public function __construct(FilesystemAdapter $adapter, array $config = [])
    {
        parent::__construct($adapter, $config);
    }

    /**
     * 兼容 Flysystem V2 的 exists() 方法
     */
    public function exists(string $path): bool
    {
        return $this->fileExists($path);
    }

    /**
     * 兼容 Flysystem V2 的 get() 方法
     */
    public function get(string $path): string
    {
        return $this->read($path);
    }

    /**
     * 兼容 Flysystem V2 的 put() 方法
     */
    public function put(string $path, $contents, array $config = []): bool
    {
        try {
            if (is_resource($contents)) {
                $this->writeStream($path, $contents, $config);
            } else {
                $this->write($path, $contents, $config);
            }
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 兼容 Flysystem V2 的 getSize() 方法
     */
    public function getSize(string $path): int
    {
        return $this->fileSize($path);
    }

    /**
     * 兼容 Flysystem V2 的 getMimetype() 方法
     */
    public function getMimetype(string $path): string
    {
        return $this->mimeType($path);
    }

    /**
     * 兼容 Flysystem V2 的 getTimestamp() 方法
     */
    public function getTimestamp(string $path): int
    {
        return $this->lastModified($path);
    }

    /**
     * 兼容 Flysystem V2 的 getVisibility() 方法
     */
    public function getVisibility(string $path): string
    {
        return $this->visibility($path);
    }

    /**
     * 兼容 Flysystem V2 的 getMetadata() 方法
     */
    public function getMetadata(string $path): array
    {
        return [
            'type' => $this->fileExists($path) ? 'file' : 'dir',
            'path' => $path,
            'timestamp' => $this->lastModified($path),
            'size' => $this->fileExists($path) ? $this->fileSize($path) : 0,
            'mimetype' => $this->fileExists($path) ? $this->mimeType($path) : null,
            'visibility' => $this->visibility($path),
        ];
    }

    /**
     * 兼容 Flysystem V2 的 rename() 方法
     */
    public function rename(string $path, string $newpath, array $config = []): bool
    {
        try {
            $this->move($path, $newpath, $config);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 兼容 Flysystem V2 的 deleteDir() 方法
     */
    public function deleteDir(string $dirname): bool
    {
        try {
            $this->deleteDirectory($dirname);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 兼容 Flysystem V2 的 createDir() 方法
     */
    public function createDir(string $dirname, array $config = []): bool
    {
        try {
            $this->createDirectory($dirname, $config);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 兼容 Flysystem V2 的 listContents() 方法返回数组
     */
    public function listContentsArray(string $directory = '', bool $recursive = false): array
    {
        $listing = $this->listContents($directory, $recursive);
        $result = [];

        foreach ($listing as $item) {
            $result[] = $item->jsonSerialize();
        }

        return $result;
    }
}

创建app/Providers/OssServiceProvider.php:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Storage;
use Illuminate\Filesystem\FilesystemAdapter;
use App\Support\OssAdapter;
use App\Support\FilesystemWrapper;
use AlibabaCloud\Oss\V2 as Oss;

class OssServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Storage::extend('oss', function ($app, $config) {
            $provider = new Oss\Credentials\StaticCredentialsProvider(
                $config['access_key_id'],
                $config['access_key_secret']
            );

            $cfg = Oss\Config::loadDefault();
            $cfg->setCredentialsProvider(credentialsProvider: $provider);
            $cfg->setRegion(region: $config['region']);

            if (!empty($config['endpoint'])) {
                $cfg->setEndpoint(endpoint: $config['endpoint']);
            }

            if ($config['use_internal'] ?? false) {
                $cfg->setUseInternalEndpoint(true);
            }

            if ($config['is_cname'] ?? false) {
                $cfg->setUseCname(true);
            }

            $client = new Oss\Client($cfg);
            $adapter = new OssAdapter($client, $config['bucket'], $config);
            $filesystem = new FilesystemWrapper($adapter, [
                'url' => $config['domain'] ?? ''
            ]);

            return new FilesystemAdapter($filesystem, $adapter, $config);
        });
    }

    public function register()
    {
        $this->app->singleton(\App\Services\Tool\OssService::class, function ($app) {
            return new \App\Services\Tool\OssService();
        });
    }
}

7. 注册 Service Provider

config/app.php 中添加:

'providers' => [
    // ...
    App\Providers\OssServiceProvider::class,
],

8. Dcat Admin 配置

config/admin.php 中:

'upload' => [
    'disk' => 'oss',
    'directory' => [
        'image' => 'images',
        'file'  => 'files',
    ],
],

9. 使用示例

在 Dcat Admin 表单中使用

use Dcat\Admin\Form;

$form->image('avatar', '头像')
    ->disk('oss')
    ->uniqueName()
    ->autoUpload();

$form->multipleImage('gallery', '相册')
    ->disk('oss')
    ->uniqueName()
    ->removable()
    ->sortable();

$form->file('attachment', '附件')
    ->disk('oss')
    ->uniqueName();

使用 Storage 门面

use Illuminate\Support\Facades\Storage;

// 上传文件
$path = Storage::disk('oss')->put('images', $request->file('image'));

// 获取 URL
$url = Storage::disk('oss')->url($path);

// 删除文件
Storage::disk('oss')->delete($path);

使用 OssService

use App\Services\Tool\OssService;

$ossService = app(OssService::class);

// 上传文件
$result = $ossService->uploadFile($request->file('image'), 'uploads/images');

if ($result['success']) {
    echo $result['url']; // 签名URL
    echo $result['public_url']; // 公共URL
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!