Selaa lähdekoodia

增加工厂成员权限,首页配置可改平台名称。

Co-authored-by: Cursor <cursoragent@cursor.com>
unknown 1 kuukausi sitten
vanhempi
sitoutus
5b4bf136e4
64 muutettua tiedostoa jossa 3995 lisäystä ja 390 poistoa
  1. 127 0
      fastadmin/application/admin/controller/ps/HomeBlock.php
  2. 22 0
      fastadmin/application/admin/lang/zh-cn/ps/home_block.php
  3. 39 0
      fastadmin/application/admin/model/ps/Home_block.php
  4. 211 0
      fastadmin/application/admin/view/ps/home_block/add.html
  5. 42 0
      fastadmin/application/admin/view/ps/home_block/index.html
  6. 173 1
      fastadmin/application/api/controller/ps/Factory.php
  7. 20 0
      fastadmin/application/api/controller/ps/Home.php
  8. 6 0
      fastadmin/application/api/controller/ps/Import.php
  9. 11 0
      fastadmin/application/api/controller/ps/Market.php
  10. 11 0
      fastadmin/application/api/controller/ps/PrintJob.php
  11. 16 0
      fastadmin/application/api/controller/ps/Product.php
  12. 20 0
      fastadmin/application/api/controller/ps/Template.php
  13. 12 0
      fastadmin/application/api/controller/ps/Workorder.php
  14. 1 1
      fastadmin/application/common/library/Auth.php
  15. 9 0
      fastadmin/application/common/library/ps/FactoryContext.php
  16. 211 0
      fastadmin/application/common/library/ps/FactoryPerm.php
  17. 293 0
      fastadmin/application/common/library/ps/HomePage.php
  18. 36 0
      fastadmin/application/sql/ps_admin_zh_menu.sql
  19. 2 0
      fastadmin/application/sql/ps_factory_perms.sql
  20. 15 0
      fastadmin/application/sql/ps_factory_perms_run.php
  21. 16 0
      fastadmin/application/sql/ps_home_block.sql
  22. 89 0
      fastadmin/application/sql/ps_home_block_run.php
  23. 2 1
      fastadmin/application/sql/ps_schema.sql
  24. 7 0
      fastadmin/application/sql/ps_workorder_product_name.sql
  25. 20 0
      fastadmin/application/sql/ps_workorder_product_name_run.php
  26. 177 0
      fastadmin/public/assets/js/backend/ps/home_block.js
  27. 1 1
      vue/public/platform-config.json
  28. 17 0
      vue/src/api/ps.ts
  29. 1 1
      vue/src/api/user.ts
  30. 0 3
      vue/src/layout/components/lay-navbar/index.vue
  31. 0 3
      vue/src/layout/components/lay-sidebar/NavHorizontal.vue
  32. 0 3
      vue/src/layout/components/lay-sidebar/NavMix.vue
  33. 3 2
      vue/src/layout/hooks/useNav.ts
  34. 2 0
      vue/src/main.ts
  35. 23 7
      vue/src/router/index.ts
  36. 2 2
      vue/src/router/modules/home.ts
  37. 2 1
      vue/src/router/modules/market.ts
  38. 21 0
      vue/src/router/modules/members.ts
  39. 3 2
      vue/src/router/modules/print.ts
  40. 4 2
      vue/src/router/modules/product.ts
  41. 9 0
      vue/src/router/modules/remaining.ts
  42. 2 1
      vue/src/router/modules/template.ts
  43. 4 2
      vue/src/router/modules/workorder.ts
  44. 43 20
      vue/src/router/utils.ts
  45. 64 0
      vue/src/store/modules/site.ts
  46. 23 4
      vue/src/store/modules/user.ts
  47. 1 0
      vue/src/store/types.ts
  48. 303 62
      vue/src/style/login.css
  49. 8 2
      vue/src/utils/http/index.ts
  50. 30 0
      vue/src/utils/print.ts
  51. 448 0
      vue/src/views/factory/Members.vue
  52. 1 0
      vue/src/views/factory/index.vue
  53. 87 82
      vue/src/views/login/index.vue
  54. 3 2
      vue/src/views/market/List.vue
  55. 12 4
      vue/src/views/print/History.vue
  56. 3 2
      vue/src/views/product/Edit.vue
  57. 6 5
      vue/src/views/product/List.vue
  58. 85 66
      vue/src/views/register/index.vue
  59. 143 39
      vue/src/views/template/List.vue
  60. 315 0
      vue/src/views/welcome/dashboard.vue
  61. 724 61
      vue/src/views/welcome/index.vue
  62. 3 2
      vue/src/views/workorder/Edit.vue
  63. 7 6
      vue/src/views/workorder/List.vue
  64. 4 0
      vue/types/router.d.ts

+ 127 - 0
fastadmin/application/admin/controller/ps/HomeBlock.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace app\admin\controller\ps;
+
+use app\common\controller\Backend;
+use app\common\library\ps\HomePage;
+use think\Db;
+use think\Exception;
+use think\exception\PDOException;
+use think\exception\ValidateException;
+
+/**
+ * 工厂端首页模块
+ *
+ * @icon fa fa-home
+ */
+class HomeBlock extends Backend
+{
+    /**
+     * @var \app\admin\model\ps\Home_block
+     */
+    protected $model = null;
+
+    protected $searchFields = 'name,title,kicker,subtitle';
+
+    protected $multiFields = 'status';
+
+    public function _initialize()
+    {
+        parent::_initialize();
+        $this->model = new \app\admin\model\ps\Home_block;
+        $this->view->assign('typeList', $this->model->getTypeList());
+        $this->view->assign('statusList', $this->model->getStatusList());
+    }
+
+    public function add()
+    {
+        if ($this->request->isPost()) {
+            $this->token();
+            $params = $this->request->post('row/a');
+            if (!$params) {
+                $this->error(__('Parameter %s can not be empty', ''));
+            }
+            $params = $this->preExcludeFields($params);
+            $params = HomePage::normalize($params);
+            $result = false;
+            Db::startTrans();
+            try {
+                $result = $this->model->allowField(true)->save($params);
+                Db::commit();
+            } catch (ValidateException|PDOException|Exception $e) {
+                Db::rollback();
+                $this->error($e->getMessage());
+            }
+            if ($result === false) {
+                $this->error(__('No rows were inserted'));
+            }
+            $this->success();
+        }
+        $this->assignForm([
+            'type'     => 'features',
+            'name'     => '',
+            'kicker'   => '',
+            'title'    => '',
+            'subtitle' => '',
+            'weigh'    => 50,
+            'status'   => 'normal',
+            'content'  => [],
+        ]);
+        return $this->view->fetch();
+    }
+
+    public function edit($ids = null)
+    {
+        $row = $this->model->get($ids);
+        if (!$row) {
+            $this->error(__('No Results were found'));
+        }
+        if ($this->request->isPost()) {
+            $this->token();
+            $params = $this->request->post('row/a');
+            if (!$params) {
+                $this->error(__('Parameter %s can not be empty', ''));
+            }
+            $params = $this->preExcludeFields($params);
+            $params = HomePage::normalize($params);
+            $result = false;
+            Db::startTrans();
+            try {
+                $result = $row->allowField(true)->save($params);
+                Db::commit();
+            } catch (ValidateException|PDOException|Exception $e) {
+                Db::rollback();
+                $this->error($e->getMessage());
+            }
+            if ($result === false) {
+                $this->error(__('No rows were updated'));
+            }
+            $this->success();
+        }
+        $data = $row->toArray();
+        $data['content'] = [];
+        if (!empty($row['content'])) {
+            $decoded = json_decode($row['content'], true);
+            if (is_array($decoded)) {
+                $data['content'] = $decoded;
+            }
+        }
+        $this->assignForm($data);
+        return $this->view->fetch('add');
+    }
+
+    /**
+     * @param array $data
+     */
+    protected function assignForm(array $data)
+    {
+        $content = isset($data['content']) && is_array($data['content']) ? $data['content'] : [];
+        $data['content'] = $content;
+        $this->view->assign('row', $data);
+        $this->view->assign('notesText', implode("\n", (array)($content['notes'] ?? [])));
+        $this->view->assign('contentJson', json_encode(
+            $content,
+            JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
+        ));
+    }
+}

+ 22 - 0
fastadmin/application/admin/lang/zh-cn/ps/home_block.php

@@ -0,0 +1,22 @@
+<?php
+
+return [
+    'Id'                     => 'ID',
+    'Type'                   => '模块类型',
+    'Name'                   => '后台备注',
+    'Kicker'                 => '小标题',
+    'Title'                  => '主标题',
+    'Subtitle'               => '说明文字',
+    'Weigh'                  => '权重',
+    'Status'                 => '状态',
+    'Normal'                 => '正常',
+    'Hidden'                 => '隐藏',
+    'Hero'                   => '头图介绍',
+    'Stats'                  => '宣传数据',
+    'Steps'                  => '操作流程',
+    'Features'               => '功能卡片',
+    'Cta'                    => '底部行动',
+    'Custom'                 => '自定义图文',
+    'Set status to normal'   => '设为显示',
+    'Set status to hidden'   => '设为隐藏',
+];

+ 39 - 0
fastadmin/application/admin/model/ps/Home_block.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace app\admin\model\ps;
+
+use app\common\library\ps\HomePage;
+use think\Model;
+
+class Home_block extends Model
+{
+    protected $name = 'ps_home_block';
+    protected $autoWriteTimestamp = 'integer';
+    protected $createTime = 'createtime';
+    protected $updateTime = 'updatetime';
+    protected $append = ['status_text', 'type_text'];
+
+    public function getTypeList()
+    {
+        return HomePage::typeList();
+    }
+
+    public function getStatusList()
+    {
+        return ['normal' => __('Normal'), 'hidden' => __('Hidden')];
+    }
+
+    public function getStatusTextAttr($value, $data)
+    {
+        $value = $value ?: ($data['status'] ?? '');
+        $list = $this->getStatusList();
+        return $list[$value] ?? '';
+    }
+
+    public function getTypeTextAttr($value, $data)
+    {
+        $value = $value ?: ($data['type'] ?? '');
+        $list = $this->getTypeList();
+        return $list[$value] ?? $value;
+    }
+}

+ 211 - 0
fastadmin/application/admin/view/ps/home_block/add.html

@@ -0,0 +1,211 @@
+<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+    {:token()}
+    <script type="application/json" id="home-block-content">{:$contentJson}</script>
+
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Type')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <select id="c-type" class="form-control" name="row[type]">
+                {foreach name="typeList" item="vo"}
+                <option value="{$key}" {if $row.type==$key}selected{/if}>{$vo}</option>
+                {/foreach}
+            </select>
+            <span class="help-block">切换类型后,下方只显示该类型需要填写的内容。</span>
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Name')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-name" data-rule="required" class="form-control" name="row[name]" type="text" value="{$row.name|htmlentities}" placeholder="仅后台列表识别,例如:头图、操作流程">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Kicker')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-kicker" class="form-control" name="row[kicker]" type="text" value="{$row.kicker|htmlentities}" placeholder="区块左上角小标题,头图一般留空">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Title')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-title" class="form-control" name="row[title]" type="text" value="{$row.title|htmlentities}" placeholder="区块主标题;头图可用作副标题">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Subtitle')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-subtitle" class="form-control" name="row[subtitle]" type="text" value="{$row.subtitle|htmlentities}" placeholder="主标题下方的说明">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Weigh')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-weigh" data-rule="required" class="form-control" name="row[weigh]" type="number" value="{$row.weigh|htmlentities}">
+            <span class="help-block">数字越大越靠前。建议:头图 100,数据 90,流程 80,功能 70,底部 60。</span>
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <div class="radio">
+                {foreach name="statusList" item="vo"}
+                <label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key}" {in name="key" value="$row.status"}checked{/in} /> {$vo}</label>
+                {/foreach}
+            </div>
+        </div>
+    </div>
+
+    <fieldset class="type-panel" data-type="hero">
+        <legend>头图介绍</legend>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">眉题:</label>
+            <div class="col-xs-12 col-sm-8">
+                <input class="form-control" name="row[content][eyebrow]" type="text" value="{$row.content.eyebrow|default=''|htmlentities}" placeholder="例如:给车间用的智能套打">
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">品牌名:</label>
+            <div class="col-xs-12 col-sm-8">
+                <input class="form-control" name="row[content][brand]" type="text" value="{$row.content.brand|default=''|htmlentities}" placeholder="例如:智印流程单">
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">导语:</label>
+            <div class="col-xs-12 col-sm-8">
+                <textarea class="form-control" name="row[content][lead]" rows="4">{$row.content.lead|default=''|htmlentities}</textarea>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">主按钮:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="row">
+                    <div class="col-xs-5">
+                        <input class="form-control" name="row[content][btn_primary_text]" type="text" value="{$row.content.btn_primary_text|default=''|htmlentities}" placeholder="按钮文字">
+                    </div>
+                    <div class="col-xs-7">
+                        <input class="form-control" name="row[content][btn_primary_path]" type="text" value="{$row.content.btn_primary_path|default=''|htmlentities}" placeholder="路径,如 /workorders/edit">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">次按钮:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="row">
+                    <div class="col-xs-5">
+                        <input class="form-control" name="row[content][btn_secondary_text]" type="text" value="{$row.content.btn_secondary_text|default=''|htmlentities}" placeholder="按钮文字">
+                    </div>
+                    <div class="col-xs-7">
+                        <input class="form-control" name="row[content][btn_secondary_path]" type="text" value="{$row.content.btn_secondary_path|default=''|htmlentities}" placeholder="路径或锚点,如 #home-flow">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">右侧标题:</label>
+            <div class="col-xs-12 col-sm-8">
+                <input class="form-control" name="row[content][panel_title]" type="text" value="{$row.content.panel_title|default=''|htmlentities}" placeholder="未登录时右侧卡片标题">
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">要点列表:</label>
+            <div class="col-xs-12 col-sm-8">
+                <textarea class="form-control" name="row[content][notes]" rows="5" placeholder="每行一条">{$notesText|htmlentities}</textarea>
+            </div>
+        </div>
+    </fieldset>
+
+    <fieldset class="type-panel" data-type="stats">
+        <legend>宣传数据</legend>
+        <p class="help-block" style="margin-top:-6px;">绑定键会读取实时看板数字;若填写固定值,则优先显示固定值。可增删条目。</p>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">数据条目:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="item-rows" data-type="stats"></div>
+                <button type="button" class="btn btn-default btn-sm btn-add-item" data-type="stats"><i class="fa fa-plus"></i> 添加条目</button>
+            </div>
+        </div>
+    </fieldset>
+
+    <fieldset class="type-panel" data-type="steps">
+        <legend>操作流程</legend>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">步骤:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="item-rows" data-type="steps"></div>
+                <button type="button" class="btn btn-default btn-sm btn-add-item" data-type="steps"><i class="fa fa-plus"></i> 添加步骤</button>
+            </div>
+        </div>
+    </fieldset>
+
+    <fieldset class="type-panel" data-type="features">
+        <legend>功能卡片</legend>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">卡片:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="item-rows" data-type="features"></div>
+                <button type="button" class="btn btn-default btn-sm btn-add-item" data-type="features"><i class="fa fa-plus"></i> 添加卡片</button>
+            </div>
+        </div>
+    </fieldset>
+
+    <fieldset class="type-panel" data-type="cta">
+        <legend>底部行动</legend>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">主按钮:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="row">
+                    <div class="col-xs-5">
+                        <input class="form-control" name="row[content][btn_primary_text]" type="text" value="{$row.content.btn_primary_text|default=''|htmlentities}" placeholder="按钮文字">
+                    </div>
+                    <div class="col-xs-7">
+                        <input class="form-control" name="row[content][btn_primary_path]" type="text" value="{$row.content.btn_primary_path|default=''|htmlentities}" placeholder="路径,如 /templates">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">次按钮:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="row">
+                    <div class="col-xs-5">
+                        <input class="form-control" name="row[content][btn_secondary_text]" type="text" value="{$row.content.btn_secondary_text|default=''|htmlentities}" placeholder="按钮文字">
+                    </div>
+                    <div class="col-xs-7">
+                        <input class="form-control" name="row[content][btn_secondary_path]" type="text" value="{$row.content.btn_secondary_path|default=''|htmlentities}" placeholder="路径,如 /workorders">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </fieldset>
+
+    <fieldset class="type-panel" data-type="custom">
+        <legend>自定义图文</legend>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">正文:</label>
+            <div class="col-xs-12 col-sm-8">
+                <textarea class="form-control" name="row[content][body]" rows="6" placeholder="纯文本,前台按段落显示">{$row.content.body|default=''|htmlentities}</textarea>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-xs-12 col-sm-2">按钮:</label>
+            <div class="col-xs-12 col-sm-8">
+                <div class="row">
+                    <div class="col-xs-5">
+                        <input class="form-control" name="row[content][btn_text]" type="text" value="{$row.content.btn_text|default=''|htmlentities}" placeholder="可留空">
+                    </div>
+                    <div class="col-xs-7">
+                        <input class="form-control" name="row[content][btn_path]" type="text" value="{$row.content.btn_path|default=''|htmlentities}" placeholder="路径">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </fieldset>
+
+    <div class="form-group layer-footer">
+        <label class="control-label col-xs-12 col-sm-2"></label>
+        <div class="col-xs-12 col-sm-8">
+            <button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
+        </div>
+    </div>
+</form>

+ 42 - 0
fastadmin/application/admin/view/ps/home_block/index.html

@@ -0,0 +1,42 @@
+<div class="panel panel-default panel-intro">
+    <div class="panel-heading">
+        {:build_heading(null,FALSE)}
+        <ul class="nav nav-tabs" data-field="status">
+            <li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
+            {foreach name="statusList" item="vo"}
+            <li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
+            {/foreach}
+        </ul>
+    </div>
+
+    <div class="panel-body">
+        <div class="alert alert-info" style="margin:0 15px 12px;">
+            这里调整工厂端首页各区块的文案和条目。权重越大越靠前;状态设为隐藏则前台不显示;删除模块即可从首页去掉该区块。前台按类型渲染:头图、宣传数据、操作流程、功能卡片、底部行动、自定义图文。
+        </div>
+        <div id="myTabContent" class="tab-content">
+            <div class="tab-pane fade active in" id="one">
+                <div class="widget-body no-padding">
+                    <div id="toolbar" class="toolbar">
+                        <a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i> </a>
+                        <a href="javascript:;" class="btn btn-success btn-add {:$auth->check('ps/home_block/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
+                        <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('ps/home_block/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
+                        <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('ps/home_block/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
+                        <div class="dropdown btn-group {:$auth->check('ps/home_block/multi')?'':'hide'}">
+                            <a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
+                            <ul class="dropdown-menu text-left" role="menu">
+                                {foreach name="statusList" item="vo"}
+                                <li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:" data-params="status={$key}">{:__('Set status to ' . $key)}</a></li>
+                                {/foreach}
+                            </ul>
+                        </div>
+                    </div>
+                    <table id="table" class="table table-striped table-bordered table-hover table-nowrap"
+                           data-operate-edit="{:$auth->check('ps/home_block/edit')}"
+                           data-operate-del="{:$auth->check('ps/home_block/del')}"
+                           width="100%">
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 173 - 1
fastadmin/application/api/controller/ps/Factory.php

@@ -4,13 +4,16 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\model\ps\Factory as FactoryModel;
+use app\common\model\User as UserModel;
 
 /**
  * 当前工厂信息
  */
 class Factory extends Api
 {
+    protected $noNeedLogin = ['board'];
     protected $noNeedRight = '*';
 
     public function index()
@@ -29,9 +32,12 @@ class Factory extends Api
         if ($this->auth->factory_role !== 'owner') {
             unset($info['invite_code'], $info['api_key_prefix'], $info['api_key_time'], $info['has_api_key']);
         }
+        $role = (string)$this->auth->factory_role;
         $this->success('', [
             'factory' => $info,
-            'role'    => $this->auth->factory_role,
+            'role'    => $role,
+            'perms'   => $role === 'owner' ? ['*:*:*'] : FactoryPerm::current(),
+            'catalog' => FactoryPerm::catalog(),
         ]);
     }
 
@@ -85,4 +91,170 @@ class Factory extends Api
             'api_key_time'   => (int)$factory['api_key_time'],
         ]);
     }
+
+    /**
+     * 本厂成员列表(仅管理员)
+     */
+    public function members()
+    {
+        $this->assertOwner();
+        $factoryId = FactoryContext::id();
+        $rows = UserModel::where('factory_id', $factoryId)
+            ->field('id,username,nickname,mobile,factory_role,factory_perms,status,logintime,createtime')
+            ->order('factory_role desc,id asc')
+            ->select();
+        $list = [];
+        foreach ($rows as $row) {
+            $raw = $row['factory_perms'];
+            $isOwner = $row['factory_role'] === 'owner';
+            $list[] = [
+                'id'           => (int)$row['id'],
+                'username'     => (string)$row['username'],
+                'nickname'     => (string)$row['nickname'],
+                'mobile'       => (string)$row['mobile'],
+                'factory_role' => (string)$row['factory_role'],
+                'is_owner'     => $isOwner,
+                'status'       => (string)$row['status'],
+                'logintime'    => (int)$row['logintime'],
+                'createtime'   => (int)$row['createtime'],
+                'perms'        => $isOwner ? FactoryPerm::keys() : array_values(array_intersect(FactoryPerm::resolve($raw), FactoryPerm::keys())),
+                'customized'   => $raw !== null && $raw !== '',
+            ];
+        }
+        $this->success('', [
+            'rows'    => $list,
+            'catalog' => FactoryPerm::catalog(),
+            'self_id' => FactoryContext::userId(),
+        ]);
+    }
+
+    /**
+     * 保存操作员权限
+     */
+    public function members_save()
+    {
+        $this->assertOwner();
+        $user = $this->memberUser();
+        if ($user['factory_role'] === 'owner') {
+            $this->error('不能限制工厂管理员的权限');
+        }
+        $keys = FactoryPerm::normalize($this->postList('perms'), false);
+        $user->allowField(['factory_perms'])->save([
+            'factory_perms' => json_encode($keys, JSON_UNESCAPED_UNICODE),
+        ]);
+        $this->success('已保存该成员权限', [
+            'id'    => (int)$user->id,
+            'perms' => array_values(array_intersect(FactoryPerm::resolve($keys), FactoryPerm::keys())),
+        ]);
+    }
+
+    /**
+     * 将操作员移出本厂
+     */
+    public function members_remove()
+    {
+        $this->assertOwner();
+        $user = $this->memberUser();
+        if ($user['factory_role'] === 'owner' || (int)$user->id === FactoryContext::userId()) {
+            $this->error('不能移出工厂管理员');
+        }
+        $user->allowField(['factory_id', 'factory_role', 'factory_perms'])->save([
+            'factory_id'    => 0,
+            'factory_role'  => '',
+            'factory_perms' => null,
+        ]);
+        $this->success('已移出本厂');
+    }
+
+    protected function assertOwner()
+    {
+        if (!FactoryContext::isOwner()) {
+            $this->error('只有工厂管理员可以管理成员权限');
+        }
+    }
+
+    /**
+     * @return UserModel
+     */
+    protected function memberUser()
+    {
+        $userId = (int)$this->request->param('user_id', $this->request->param('id'));
+        if ($userId <= 0) {
+            $input = json_decode((string)$this->request->getInput(), true);
+            if (is_array($input)) {
+                $userId = (int)($input['user_id'] ?? $input['id'] ?? 0);
+            }
+        }
+        if ($userId <= 0) {
+            $this->error('请选择成员');
+        }
+        $user = UserModel::get($userId);
+        if (!$user || (int)$user['factory_id'] !== FactoryContext::id()) {
+            $this->error('成员不存在');
+        }
+        return $user;
+    }
+
+    /**
+     * @param string $name
+     * @return array
+     */
+    protected function postList($name)
+    {
+        $val = $this->request->post($name . '/a');
+        if (is_array($val)) {
+            return $val;
+        }
+        $val = $this->request->param($name);
+        if (is_array($val)) {
+            return $val;
+        }
+        if (is_string($val) && $val !== '') {
+            $decoded = json_decode($val, true);
+            if (is_array($decoded)) {
+                return $decoded;
+            }
+            return array_values(array_filter(array_map('trim', explode(',', $val))));
+        }
+        $input = json_decode((string)$this->request->getInput(), true);
+        if (is_array($input) && isset($input[$name]) && is_array($input[$name])) {
+            return $input[$name];
+        }
+        return [];
+    }
+
+    /**
+     * 首页看板:本厂数据 + 平台宣传数据
+     */
+    public function board()
+    {
+        $factoryId = FactoryContext::id();
+        $today = strtotime(date('Y-m-d'));
+        $factory = $factoryId ? FactoryModel::get($factoryId) : null;
+        $printToday = \think\Db::name('ps_print_job')->where('createtime', '>=', $today)->sum('copies');
+        $printTotal = \think\Db::name('ps_print_job')->sum('copies');
+        $minePrint = $factoryId
+            ? \think\Db::name('ps_print_job')->where('factory_id', $factoryId)->sum('copies')
+            : 0;
+        $this->success('', [
+            'platform' => [
+                'factory_count'  => (int)\think\Db::name('ps_factory')->where('status', 'normal')->count(),
+                'print_today'    => (int)$printToday,
+                'print_total'    => (int)$printTotal,
+                'template_count' => (int)\think\Db::name('ps_template')->where('status', 'normal')->count(),
+            ],
+            'mine'     => [
+                'score'       => $factory ? (int)$factory['score'] : 0,
+                'product'     => $factoryId ? (int)\think\Db::name('ps_product')->where('factory_id', $factoryId)->count() : 0,
+                'workorder'   => $factoryId ? (int)\think\Db::name('ps_workorder')->where('factory_id', $factoryId)->count() : 0,
+                'print'       => (int)$minePrint,
+                'print_today' => $factoryId
+                    ? (int)\think\Db::name('ps_print_job')->where('factory_id', $factoryId)->where('createtime', '>=', $today)->sum('copies')
+                    : 0,
+                'template'    => $factoryId
+                    ? (int)\think\Db::name('ps_template')->where('factory_id', $factoryId)->where('status', 'normal')->count()
+                    : 0,
+            ],
+        ]);
+    }
 }

+ 20 - 0
fastadmin/application/api/controller/ps/Home.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace app\api\controller\ps;
+
+use app\common\controller\Api;
+use app\common\library\ps\HomePage;
+
+/**
+ * 工厂端首页模块(公开)
+ */
+class Home extends Api
+{
+    protected $noNeedLogin = ['*'];
+    protected $noNeedRight = '*';
+
+    public function index()
+    {
+        $this->success('', HomePage::published());
+    }
+}

+ 6 - 0
fastadmin/application/api/controller/ps/Import.php

@@ -6,6 +6,7 @@ use app\common\controller\Api;
 use app\common\library\ps\BizExtractor;
 use app\common\library\ps\BizIngest;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 
 /**
  * 文件导入产品 / 工单(登录工厂账号)
@@ -20,6 +21,11 @@ class Import extends Api
         if (!FactoryContext::id()) {
             $this->error('账号未绑定工厂');
         }
+        $type = $this->request->param('type', 'workorder');
+        $perm = $type === 'product' ? 'product.import' : 'workorder.import';
+        if (FactoryPerm::deny($perm)) {
+            $this->error('没有权限:' . FactoryPerm::label($perm));
+        }
     }
 
     /**

+ 11 - 0
fastadmin/application/api/controller/ps/Market.php

@@ -4,6 +4,7 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\library\ps\MarketBuyer;
 use app\common\library\ps\MarketPublisher;
 use app\common\model\ps\MarketGoods;
@@ -23,6 +24,16 @@ class Market extends Api
         if (!FactoryContext::id()) {
             $this->error('账号未绑定工厂');
         }
+        $action = strtolower($this->request->action());
+        if ($action === 'buy') {
+            if (FactoryPerm::deny('market.buy')) {
+                $this->error('没有权限:' . FactoryPerm::label('market.buy'));
+            }
+        } elseif (in_array($action, ['index', 'info', 'mine', 'orders'], true)) {
+            if (FactoryPerm::deny('market.view')) {
+                $this->error('没有权限:' . FactoryPerm::label('market.view'));
+            }
+        }
     }
 
     /**

+ 11 - 0
fastadmin/application/api/controller/ps/PrintJob.php

@@ -4,6 +4,7 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\library\ps\LodopPrinter;
 use app\common\library\ps\PaperSize;
 use app\common\library\ps\PrintPlanner;
@@ -28,6 +29,16 @@ class PrintJob extends Api
         if (!FactoryContext::id()) {
             $this->error('账号未绑定工厂');
         }
+        $map = [
+            'index'   => 'print.view',
+            'prepare' => 'print.do',
+            'confirm' => 'print.do',
+            'del'     => 'print.delete',
+        ];
+        $action = strtolower($this->request->action());
+        if (isset($map[$action]) && FactoryPerm::deny($map[$action])) {
+            $this->error('没有权限:' . FactoryPerm::label($map[$action]));
+        }
     }
 
     /**

+ 16 - 0
fastadmin/application/api/controller/ps/Product.php

@@ -4,6 +4,7 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\library\ps\ProcessWriter;
 use app\common\model\ps\Product as ProductModel;
 
@@ -26,6 +27,21 @@ class Product extends Api
             $this->error('账号未绑定工厂');
         }
         $this->model = new ProductModel;
+        $action = strtolower($this->request->action());
+        if ($action === 'index' || $action === 'info') {
+            if (!FactoryPerm::canAny(['product.view', 'workorder.view', 'workorder.edit', 'print.do'])) {
+                $this->error('没有权限查看产品');
+            }
+        } else {
+            $map = [
+                'add' => 'product.edit',
+                'edit' => 'product.edit',
+                'del' => 'product.delete',
+            ];
+            if (isset($map[$action]) && FactoryPerm::deny($map[$action])) {
+                $this->error('没有权限:' . FactoryPerm::label($map[$action]));
+            }
+        }
     }
 
     /**

+ 20 - 0
fastadmin/application/api/controller/ps/Template.php

@@ -4,6 +4,7 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\library\ps\MarketPublisher;
 use app\common\model\ps\MarketGoods;
 use app\common\model\ps\Template as TemplateModel;
@@ -27,6 +28,25 @@ class Template extends Api
             $this->error('账号未绑定工厂');
         }
         $this->model = new TemplateModel;
+        $action = strtolower($this->request->action());
+        if ($action === 'index' || $action === 'info') {
+            if (!FactoryPerm::canAny(['template.view', 'print.do'])) {
+                $this->error('没有权限查看模板');
+            }
+        } else {
+            $map = [
+                'saveschema'    => 'template.edit',
+                'save_schema'   => 'template.edit',
+                'add'           => 'template.edit',
+                'edit'          => 'template.edit',
+                'create_blank'  => 'template.edit',
+                'copy'          => 'template.edit',
+                'del'           => 'template.delete',
+            ];
+            if (isset($map[$action]) && FactoryPerm::deny($map[$action])) {
+                $this->error('没有权限:' . FactoryPerm::label($map[$action]));
+            }
+        }
     }
 
     /**

+ 12 - 0
fastadmin/application/api/controller/ps/Workorder.php

@@ -4,6 +4,7 @@ namespace app\api\controller\ps;
 
 use app\common\controller\Api;
 use app\common\library\ps\FactoryContext;
+use app\common\library\ps\FactoryPerm;
 use app\common\library\ps\ProcessWriter;
 use app\common\model\ps\Product as ProductModel;
 use app\common\model\ps\Workorder as WorkorderModel;
@@ -27,6 +28,17 @@ class Workorder extends Api
             $this->error('账号未绑定工厂');
         }
         $this->model = new WorkorderModel;
+        $map = [
+            'index' => 'workorder.view',
+            'info'  => 'workorder.view',
+            'add'   => 'workorder.edit',
+            'edit'  => 'workorder.edit',
+            'del'   => 'workorder.delete',
+        ];
+        $action = strtolower($this->request->action());
+        if (isset($map[$action]) && FactoryPerm::deny($map[$action])) {
+            $this->error('没有权限:' . FactoryPerm::label($map[$action]));
+        }
     }
 
     /**

+ 1 - 1
fastadmin/application/common/library/Auth.php

@@ -26,7 +26,7 @@ class Auth
     //默认配置
     protected $config = [];
     protected $options = [];
-    protected $allowFields = ['id', 'username', 'nickname', 'mobile', 'avatar', 'score', 'factory_id', 'factory_role'];
+    protected $allowFields = ['id', 'username', 'nickname', 'mobile', 'avatar', 'score', 'factory_id', 'factory_role', 'factory_perms'];
 
     public function __construct($options = [])
     {

+ 9 - 0
fastadmin/application/common/library/ps/FactoryContext.php

@@ -73,6 +73,15 @@ class FactoryContext
         return $auth && $auth->isLogin() && $auth->factory_role === 'owner';
     }
 
+    /**
+     * Open API 已按 Key 绑定工厂(无会员登录)
+     * @return bool
+     */
+    public static function isApiBound()
+    {
+        return self::$forcedId > 0;
+    }
+
     /**
      * 未入厂则抛业务异常给 API 基类处理
      * @return int

+ 211 - 0
fastadmin/application/common/library/ps/FactoryPerm.php

@@ -0,0 +1,211 @@
+<?php
+
+namespace app\common\library\ps;
+
+use app\common\library\Auth as FrontendAuth;
+
+/**
+ * 工厂端成员权限。管理员始终全开;操作员按 factory_perms 限制。
+ */
+class FactoryPerm
+{
+    const ALWAYS = ['factory.view'];
+
+    /**
+     * 可分配给操作员的权限(不含管理员专属)
+     * @return array
+     */
+    public static function catalog()
+    {
+        return [
+            ['key' => 'workorder.view', 'label' => '查看工单', 'group' => '工单套打'],
+            ['key' => 'workorder.edit', 'label' => '新建 / 编辑工单', 'group' => '工单套打'],
+            ['key' => 'workorder.delete', 'label' => '删除工单', 'group' => '工单套打'],
+            ['key' => 'workorder.import', 'label' => '文件导入工单', 'group' => '工单套打'],
+            ['key' => 'product.view', 'label' => '查看产品', 'group' => '产品工艺'],
+            ['key' => 'product.edit', 'label' => '新建 / 编辑产品', 'group' => '产品工艺'],
+            ['key' => 'product.delete', 'label' => '删除产品', 'group' => '产品工艺'],
+            ['key' => 'product.import', 'label' => '文件导入产品', 'group' => '产品工艺'],
+            ['key' => 'template.view', 'label' => '查看模板', 'group' => '本厂模板'],
+            ['key' => 'template.edit', 'label' => '修改本厂模板', 'group' => '本厂模板'],
+            ['key' => 'template.delete', 'label' => '删除本厂模板', 'group' => '本厂模板'],
+            ['key' => 'print.do', 'label' => '套打打印', 'group' => '套打'],
+            ['key' => 'print.view', 'label' => '查看打印记录', 'group' => '套打'],
+            ['key' => 'print.delete', 'label' => '删除打印记录', 'group' => '套打'],
+            ['key' => 'market.view', 'label' => '浏览商城', 'group' => '模板商城'],
+            ['key' => 'market.buy', 'label' => '购买模板', 'group' => '模板商城'],
+        ];
+    }
+
+    /**
+     * @return string[]
+     */
+    public static function keys()
+    {
+        $keys = [];
+        foreach (self::catalog() as $item) {
+            $keys[] = $item['key'];
+        }
+        return $keys;
+    }
+
+    /**
+     * @param string $key
+     * @return string
+     */
+    public static function label($key)
+    {
+        foreach (self::catalog() as $item) {
+            if ($item['key'] === $key) {
+                return $item['label'];
+            }
+        }
+        if ($key === 'factory.view') {
+            return '查看本厂信息';
+        }
+        return $key;
+    }
+
+    /**
+     * 未单独配置时,操作员默认拥有全部业务权限(与上线前行为一致)
+     * @return string[]
+     */
+    public static function defaults()
+    {
+        return array_values(array_unique(array_merge(self::ALWAYS, self::keys())));
+    }
+
+    /**
+     * @param mixed $raw
+     * @return string[]|null null 表示沿用默认
+     */
+    public static function decode($raw)
+    {
+        if ($raw === null || $raw === '') {
+            return null;
+        }
+        if (is_array($raw)) {
+            $list = $raw;
+        } else {
+            $list = json_decode((string)$raw, true);
+        }
+        if (!is_array($list)) {
+            return null;
+        }
+        return self::normalize($list, false);
+    }
+
+    /**
+     * @param array $keys
+     * @param bool  $keepAlways
+     * @return string[]
+     */
+    public static function normalize(array $keys, $keepAlways = true)
+    {
+        $allow = array_flip(self::keys());
+        $out = [];
+        foreach ($keys as $key) {
+            $key = trim((string)$key);
+            if ($key !== '' && isset($allow[$key])) {
+                $out[$key] = $key;
+            }
+        }
+        $implies = [
+            'workorder.edit'   => 'workorder.view',
+            'workorder.delete' => 'workorder.view',
+            'workorder.import' => 'workorder.view',
+            'product.edit'     => 'product.view',
+            'product.delete'   => 'product.view',
+            'product.import'   => 'product.view',
+            'template.edit'    => 'template.view',
+            'template.delete'  => 'template.view',
+            'print.delete'     => 'print.view',
+            'market.buy'       => 'market.view',
+        ];
+        foreach ($implies as $from => $to) {
+            if (isset($out[$from])) {
+                $out[$to] = $to;
+            }
+        }
+        if ($keepAlways) {
+            foreach (self::ALWAYS as $key) {
+                $out[$key] = $key;
+            }
+        }
+        return array_values($out);
+    }
+
+    /**
+     * @param mixed $raw
+     * @return string[]
+     */
+    public static function resolve($raw)
+    {
+        $decoded = self::decode($raw);
+        return $decoded === null ? self::defaults() : self::normalize($decoded, true);
+    }
+
+    /**
+     * @param string $perm
+     * @return bool
+     */
+    public static function can($perm)
+    {
+        $perm = trim((string)$perm);
+        if ($perm === '') {
+            return true;
+        }
+        if (FactoryContext::bypass() || FactoryContext::isApiBound()) {
+            return true;
+        }
+        if (FactoryContext::isOwner()) {
+            return true;
+        }
+        $auth = FrontendAuth::instance();
+        if (!$auth || !$auth->isLogin()) {
+            return false;
+        }
+        $raw = isset($auth->factory_perms) ? $auth->factory_perms : '';
+        return in_array($perm, self::resolve($raw), true);
+    }
+
+    /**
+     * @param array $perms
+     * @return bool
+     */
+    public static function canAny(array $perms)
+    {
+        foreach ($perms as $perm) {
+            if (self::can($perm)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @param string $perm
+     * @return bool
+     */
+    public static function deny($perm)
+    {
+        return !self::can($perm);
+    }
+
+    /**
+     * 当前登录成员的有效权限
+     * @return string[]
+     */
+    public static function current()
+    {
+        if (FactoryContext::bypass() || FactoryContext::isApiBound() || FactoryContext::isOwner()) {
+            return array_values(array_unique(array_merge(['*:*:*'], self::defaults())));
+        }
+        $auth = FrontendAuth::instance();
+        if (!$auth || !$auth->isLogin()) {
+            return self::ALWAYS;
+        }
+        $raw = isset($auth->factory_perms) ? $auth->factory_perms : '';
+        return self::resolve($raw);
+    }
+}

+ 293 - 0
fastadmin/application/common/library/ps/HomePage.php

@@ -0,0 +1,293 @@
+<?php
+
+namespace app\common\library\ps;
+
+use think\Db;
+
+/**
+ * 工厂端首页模块。后台可增删改,前台按权重渲染。
+ */
+class HomePage
+{
+    public static function typeList()
+    {
+        return [
+            'hero'     => '头图介绍',
+            'stats'    => '宣传数据',
+            'steps'    => '操作流程',
+            'features' => '功能卡片',
+            'cta'      => '底部行动',
+            'custom'   => '自定义图文',
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public static function published()
+    {
+        try {
+            $rows = Db::name('ps_home_block')->where('status', 'normal')->order('weigh desc,id asc')->select();
+        } catch (\Exception $e) {
+            return self::defaults();
+        }
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = self::formatRow($row);
+        }
+        return $out;
+    }
+
+    /**
+     * @param array $row
+     * @return array
+     */
+    public static function formatRow(array $row)
+    {
+        $content = [];
+        if (!empty($row['content'])) {
+            $decoded = json_decode($row['content'], true);
+            if (is_array($decoded)) {
+                $content = $decoded;
+            }
+        }
+        return [
+            'id'       => (int)$row['id'],
+            'type'     => (string)$row['type'],
+            'name'     => (string)$row['name'],
+            'kicker'   => (string)$row['kicker'],
+            'title'    => (string)$row['title'],
+            'subtitle' => (string)$row['subtitle'],
+            'weigh'    => (int)$row['weigh'],
+            'content'  => $content,
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public static function defaults()
+    {
+        $now = time();
+        $rows = [
+            [
+                'id'       => 0,
+                'type'     => 'hero',
+                'name'     => '头图',
+                'kicker'   => '',
+                'title'    => '智印流程单',
+                'subtitle' => '把工单数据,准确地打在已印好的纸上',
+                'weigh'    => 100,
+                'content'  => [
+                    'eyebrow'            => '给车间用的智能套打',
+                    'brand'              => '智印流程单',
+                    'lead'               => '文件导入就能识别产品与工序,选好模板、填好数量,即可批量套打。模板可自制,也可在商城里选用,每次打印都留得下记录。',
+                    'btn_primary_text'   => '开始套打',
+                    'btn_primary_path'   => '/workorders/edit',
+                    'btn_secondary_text' => '了解流程',
+                    'btn_secondary_path' => '#home-flow',
+                    'panel_title'        => '今天的车间',
+                    'notes'              => [
+                        '导入文件,AI 帮你认出编号和工序',
+                        '数量只在套打时填写,一版一张',
+                        '按原纸格子对齐,字打在该打的地方',
+                    ],
+                ],
+            ],
+            [
+                'id'       => 0,
+                'type'     => 'stats',
+                'name'     => '宣传数据',
+                'kicker'   => '',
+                'title'    => '',
+                'subtitle' => '',
+                'weigh'    => 90,
+                'content'  => [
+                    'items' => [
+                        ['label' => '入驻厂家', 'key' => 'factory_count', 'value' => ''],
+                        ['label' => '今日打印量', 'key' => 'print_today', 'value' => ''],
+                        ['label' => '累计套打张数', 'key' => 'print_total', 'value' => ''],
+                        ['label' => '可用模板', 'key' => 'template_count', 'value' => ''],
+                    ],
+                ],
+            ],
+            [
+                'id'       => 0,
+                'type'     => 'steps',
+                'name'     => '操作流程',
+                'kicker'   => '怎么用',
+                'title'    => '四步走完,就能出单',
+                'subtitle' => '从建档到打印,车间按这个顺序做就行,不用先学一堆设置。',
+                'weigh'    => 80,
+                'content'  => [
+                    'items' => [
+                        ['title' => '建档或导入', 'desc' => '手工新建产品 / 工单,或用文件导入让 AI 识别编号、名称和工序。'],
+                        ['title' => '准备模板', 'desc' => '在本厂模板中选用已识版样式,也可到模板商城补充官方或市场模板。'],
+                        ['title' => '套打出单', 'desc' => '填写本次总数量和每版数量,预览拆张后确认打印,只印字段不印底图。'],
+                        ['title' => '记录与分析', 'desc' => '在打印日志里查看流水和补打记录,首页看板同步展示打印量与工厂数据。'],
+                    ],
+                ],
+            ],
+            [
+                'id'       => 0,
+                'type'     => 'features',
+                'name'     => '功能介绍',
+                'kicker'   => '能做什么',
+                'title'    => '日常出单用得到的能力',
+                'subtitle' => '识别、套打、模板、商城、日志和分析都在这一处,点卡片就能进去。',
+                'weigh'    => 70,
+                'content'  => [
+                    'items' => [
+                        ['tag' => '识别', 'title' => '文件导入 AI 识别', 'desc' => '上传 Excel、PDF 或图片,自动提取编号、名称与工序,核对后写入本厂档案。', 'path' => '/workorders'],
+                        ['tag' => '套打', 'title' => '批量打印', 'desc' => '按总数量和每版数量自动拆张,一次确认即可连续套打多张流程单。', 'path' => '/templates'],
+                        ['tag' => '模板', 'title' => '自定义模板', 'desc' => '上传车间流程单识版,格子坐标与字段对齐后,按原纸套打不跑偏。', 'path' => '/templates'],
+                        ['tag' => '商城', 'title' => '模板商城', 'desc' => '选用官方或市场模板,单次或买断授权,快速补齐本厂可套打样式。', 'path' => '/market'],
+                        ['tag' => '日志', 'title' => '打印日志', 'desc' => '每次套打留流水号、模板与张数,便于车间对账、补打和追溯。', 'path' => '/print/history'],
+                        ['tag' => '分析', 'title' => '数据智能分析', 'desc' => '汇总入驻工厂、日打印量、本厂工单与套打次数,看清车间打印节奏。', 'path' => '/print/history'],
+                    ],
+                ],
+            ],
+            [
+                'id'       => 0,
+                'type'     => 'cta',
+                'name'     => '底部行动',
+                'kicker'   => '',
+                'title'    => '准备好就开打',
+                'subtitle' => '选一张本厂模板,填上本次数量,流程单就能按原纸套出来。',
+                'weigh'    => 60,
+                'content'  => [
+                    'btn_primary_text'     => '选择模板套打',
+                    'btn_primary_path'     => '/templates',
+                    'btn_secondary_text'   => '进入工单',
+                    'btn_secondary_path'   => '/workorders',
+                ],
+            ],
+        ];
+        foreach ($rows as &$row) {
+            $row['createtime'] = $now;
+            $row['updatetime'] = $now;
+            $row['status'] = 'normal';
+        }
+        unset($row);
+        return $rows;
+    }
+
+    /**
+     * @param array $params
+     * @return array
+     */
+    public static function normalize(array $params)
+    {
+        $type = isset($params['type']) ? (string)$params['type'] : 'custom';
+        if (!isset(self::typeList()[$type])) {
+            $type = 'custom';
+        }
+        $params['type'] = $type;
+        $params['name'] = mb_substr(trim((string)($params['name'] ?? '')), 0, 64);
+        $params['kicker'] = mb_substr(trim((string)($params['kicker'] ?? '')), 0, 64);
+        $params['title'] = mb_substr(trim((string)($params['title'] ?? '')), 0, 120);
+        $params['subtitle'] = mb_substr(trim((string)($params['subtitle'] ?? '')), 0, 255);
+        $params['weigh'] = (int)($params['weigh'] ?? 0);
+        $params['status'] = ($params['status'] ?? '') === 'hidden' ? 'hidden' : 'normal';
+        $raw = $params['content_json'] ?? $params['content'] ?? [];
+        if (is_string($raw)) {
+            $decoded = json_decode($raw, true);
+            $raw = is_array($decoded) ? $decoded : [];
+        }
+        if (!is_array($raw)) {
+            $raw = [];
+        }
+        $params['content'] = json_encode(self::cleanContent($type, $raw), JSON_UNESCAPED_UNICODE);
+        unset($params['content_json'], $params['items'], $params['notes']);
+        return $params;
+    }
+
+    /**
+     * @param string $type
+     * @param array  $raw
+     * @return array
+     */
+    public static function cleanContent($type, array $raw)
+    {
+        $str = function ($key, $max = 255) use ($raw) {
+            return mb_substr(trim((string)($raw[$key] ?? '')), 0, $max);
+        };
+        if ($type === 'hero') {
+            $notes = $raw['notes'] ?? [];
+            if (is_string($notes)) {
+                $notes = preg_split('/\r\n|\n/', $notes);
+            }
+            $cleanNotes = [];
+            foreach ((array)$notes as $note) {
+                $note = trim((string)$note);
+                if ($note !== '') {
+                    $cleanNotes[] = mb_substr($note, 0, 120);
+                }
+            }
+            return [
+                'eyebrow'            => $str('eyebrow', 40),
+                'brand'              => $str('brand', 40),
+                'lead'               => $str('lead', 500),
+                'btn_primary_text'   => $str('btn_primary_text', 20),
+                'btn_primary_path'   => $str('btn_primary_path', 120),
+                'btn_secondary_text' => $str('btn_secondary_text', 20),
+                'btn_secondary_path' => $str('btn_secondary_path', 120),
+                'panel_title'        => $str('panel_title', 40),
+                'notes'              => $cleanNotes,
+            ];
+        }
+        if ($type === 'stats' || $type === 'steps' || $type === 'features') {
+            $items = [];
+            foreach ((array)($raw['items'] ?? []) as $item) {
+                if (!is_array($item)) {
+                    continue;
+                }
+                if ($type === 'stats') {
+                    $label = mb_substr(trim((string)($item['label'] ?? '')), 0, 40);
+                    if ($label === '') {
+                        continue;
+                    }
+                    $items[] = [
+                        'label' => $label,
+                        'key'   => mb_substr(trim((string)($item['key'] ?? '')), 0, 32),
+                        'value' => mb_substr(trim((string)($item['value'] ?? '')), 0, 32),
+                    ];
+                } elseif ($type === 'steps') {
+                    $title = mb_substr(trim((string)($item['title'] ?? '')), 0, 40);
+                    if ($title === '') {
+                        continue;
+                    }
+                    $items[] = [
+                        'title' => $title,
+                        'desc'  => mb_substr(trim((string)($item['desc'] ?? '')), 0, 200),
+                    ];
+                } else {
+                    $title = mb_substr(trim((string)($item['title'] ?? '')), 0, 40);
+                    if ($title === '') {
+                        continue;
+                    }
+                    $items[] = [
+                        'tag'   => mb_substr(trim((string)($item['tag'] ?? '')), 0, 12),
+                        'title' => $title,
+                        'desc'  => mb_substr(trim((string)($item['desc'] ?? '')), 0, 200),
+                        'path'  => mb_substr(trim((string)($item['path'] ?? '')), 0, 120),
+                    ];
+                }
+            }
+            return ['items' => $items];
+        }
+        if ($type === 'cta') {
+            return [
+                'btn_primary_text'   => $str('btn_primary_text', 20),
+                'btn_primary_path'   => $str('btn_primary_path', 120),
+                'btn_secondary_text' => $str('btn_secondary_text', 20),
+                'btn_secondary_path' => $str('btn_secondary_path', 120),
+            ];
+        }
+        return [
+            'body'     => $str('body', 2000),
+            'btn_text' => $str('btn_text', 20),
+            'btn_path' => $str('btn_path', 120),
+        ];
+    }
+}

+ 36 - 0
fastadmin/application/sql/ps_admin_zh_menu.sql

@@ -93,6 +93,42 @@ JOIN `fa_auth_rule` p ON p.name='ps/group_factory'
 SET a.pid=p.id, a.title='字段字典', a.weigh=10
 WHERE a.name='ps/field_dict';
 
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', p.id, 'ps/home_block', '工厂端首页', 'fa fa-home', 1, 'normal', 40, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` p
+WHERE p.name='ps/group_factory'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block');
+
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', a.id, 'ps/home_block/index', '查看', 'fa fa-circle-o', 0, 'normal', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` a
+WHERE a.name='ps/home_block'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block/index');
+
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', a.id, 'ps/home_block/add', '添加', 'fa fa-circle-o', 0, 'normal', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` a
+WHERE a.name='ps/home_block'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block/add');
+
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', a.id, 'ps/home_block/edit', '编辑', 'fa fa-circle-o', 0, 'normal', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` a
+WHERE a.name='ps/home_block'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block/edit');
+
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', a.id, 'ps/home_block/del', '删除', 'fa fa-circle-o', 0, 'normal', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` a
+WHERE a.name='ps/home_block'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block/del');
+
+INSERT INTO `fa_auth_rule` (`type`,`pid`,`name`,`title`,`icon`,`ismenu`,`status`,`weigh`,`createtime`,`updatetime`)
+SELECT 'file', a.id, 'ps/home_block/multi', '批量更新', 'fa fa-circle-o', 0, 'normal', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+FROM `fa_auth_rule` a
+WHERE a.name='ps/home_block'
+AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name`='ps/home_block/multi');
+
 UPDATE `fa_auth_rule` a
 JOIN `fa_auth_rule` p ON p.name='ps/group_template'
 SET a.pid=p.id, a.title='模板', a.weigh=30

+ 2 - 0
fastadmin/application/sql/ps_factory_perms.sql

@@ -0,0 +1,2 @@
+ALTER TABLE `fa_user`
+  ADD COLUMN `factory_perms` text NULL COMMENT '工厂端权限JSON,空=操作员默认全开' AFTER `factory_role`;

+ 15 - 0
fastadmin/application/sql/ps_factory_perms_run.php

@@ -0,0 +1,15 @@
+<?php
+$root = dirname(dirname(__DIR__));
+define('APP_PATH', $root . '/application/');
+require $root . '/thinkphp/base.php';
+\think\App::initCommon();
+
+$prefix = \think\Db::getConfig('prefix');
+$table = $prefix . 'user';
+$rows = \think\Db::query("SHOW COLUMNS FROM `{$table}` LIKE 'factory_perms'");
+if ($rows) {
+    echo $table . ".factory_perms exists\n";
+} else {
+    \think\Db::execute("ALTER TABLE `{$table}` ADD COLUMN `factory_perms` text NULL COMMENT '工厂端权限JSON,空=操作员默认全开' AFTER `factory_role`");
+    echo $table . ".factory_perms added\n";
+}

+ 16 - 0
fastadmin/application/sql/ps_home_block.sql

@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS `fa_ps_home_block` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `type` varchar(20) NOT NULL DEFAULT 'custom' COMMENT 'hero/stats/steps/features/cta/custom',
+  `name` varchar(64) NOT NULL DEFAULT '' COMMENT '后台备注名',
+  `kicker` varchar(64) NOT NULL DEFAULT '' COMMENT '小标题',
+  `title` varchar(120) NOT NULL DEFAULT '' COMMENT '主标题',
+  `subtitle` varchar(255) NOT NULL DEFAULT '' COMMENT '说明',
+  `content` text COMMENT '模块内容 JSON',
+  `weigh` int(11) NOT NULL DEFAULT '0' COMMENT '越大越靠前',
+  `status` enum('normal','hidden') NOT NULL DEFAULT 'normal',
+  `createtime` bigint(16) DEFAULT NULL,
+  `updatetime` bigint(16) DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `weigh` (`weigh`),
+  KEY `status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='工厂端首页模块';

+ 89 - 0
fastadmin/application/sql/ps_home_block_run.php

@@ -0,0 +1,89 @@
+<?php
+$root = dirname(dirname(__DIR__));
+define('APP_PATH', $root . '/application/');
+require $root . '/thinkphp/base.php';
+\think\App::initCommon();
+
+$prefix = \think\Db::getConfig('prefix');
+$table = $prefix . 'ps_home_block';
+$exists = \think\Db::query("SHOW TABLES LIKE '" . addslashes($table) . "'");
+if (!$exists) {
+    \think\Db::execute("CREATE TABLE `{$table}` (
+      `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+      `type` varchar(20) NOT NULL DEFAULT 'custom' COMMENT 'hero/stats/steps/features/cta/custom',
+      `name` varchar(64) NOT NULL DEFAULT '' COMMENT '后台备注名',
+      `kicker` varchar(64) NOT NULL DEFAULT '' COMMENT '小标题',
+      `title` varchar(120) NOT NULL DEFAULT '' COMMENT '主标题',
+      `subtitle` varchar(255) NOT NULL DEFAULT '' COMMENT '说明',
+      `content` text COMMENT '模块内容 JSON',
+      `weigh` int(11) NOT NULL DEFAULT '0' COMMENT '越大越靠前',
+      `status` enum('normal','hidden') NOT NULL DEFAULT 'normal',
+      `createtime` bigint(16) DEFAULT NULL,
+      `updatetime` bigint(16) DEFAULT NULL,
+      PRIMARY KEY (`id`),
+      KEY `weigh` (`weigh`),
+      KEY `status` (`status`)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='工厂端首页模块'");
+    echo $table . " created\n";
+} else {
+    echo $table . " exists\n";
+}
+
+$count = (int)\think\Db::name('ps_home_block')->count();
+if ($count === 0) {
+    $now = time();
+    foreach (\app\common\library\ps\HomePage::defaults() as $row) {
+        unset($row['id']);
+        $row['content'] = json_encode($row['content'], JSON_UNESCAPED_UNICODE);
+        $row['createtime'] = $now;
+        $row['updatetime'] = $now;
+        \think\Db::name('ps_home_block')->insert($row);
+    }
+    echo "seeded defaults\n";
+} else {
+    echo "blocks already " . $count . "\n";
+}
+
+$pid = (int)\think\Db::name('auth_rule')->where('name', 'ps/group_factory')->value('id');
+if ($pid <= 0) {
+    echo "menu parent missing\n";
+    return;
+}
+$menu = \think\Db::name('auth_rule')->where('name', 'ps/home_block')->find();
+if (!$menu) {
+    $id = \think\Db::name('auth_rule')->insertGetId([
+        'type'       => 'file',
+        'pid'        => $pid,
+        'name'       => 'ps/home_block',
+        'title'      => '工厂端首页',
+        'icon'       => 'fa fa-home',
+        'ismenu'     => 1,
+        'status'     => 'normal',
+        'weigh'      => 40,
+        'createtime' => time(),
+        'updatetime' => time(),
+    ]);
+    echo "menu added {$id}\n";
+} else {
+    $id = (int)$menu['id'];
+    echo "menu exists {$id}\n";
+}
+foreach (['index' => '查看', 'add' => '添加', 'edit' => '编辑', 'del' => '删除', 'multi' => '批量更新'] as $act => $title) {
+    $name = 'ps/home_block/' . $act;
+    if (\think\Db::name('auth_rule')->where('name', $name)->find()) {
+        continue;
+    }
+    \think\Db::name('auth_rule')->insert([
+        'type'       => 'file',
+        'pid'        => $id,
+        'name'       => $name,
+        'title'      => $title,
+        'icon'       => 'fa fa-circle-o',
+        'ismenu'     => 0,
+        'status'     => 'normal',
+        'weigh'      => 0,
+        'createtime' => time(),
+        'updatetime' => time(),
+    ]);
+    echo "rule {$name}\n";
+}

+ 2 - 1
fastadmin/application/sql/ps_schema.sql

@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS `fa_ps_factory` (
 -- ----------------------------
 ALTER TABLE `fa_user`
   ADD COLUMN `factory_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属工厂' AFTER `group_id`,
-  ADD COLUMN `factory_role` varchar(20) NOT NULL DEFAULT '' COMMENT 'owner=管理员,operator=操作员' AFTER `factory_id`;
+  ADD COLUMN `factory_role` varchar(20) NOT NULL DEFAULT '' COMMENT 'owner=管理员,operator=操作员' AFTER `factory_id`,
+  ADD COLUMN `factory_perms` text NULL COMMENT '工厂端权限JSON,空=操作员默认全开' AFTER `factory_role`;
 
 ALTER TABLE `fa_user` ADD INDEX `factory_id` (`factory_id`);
 

+ 7 - 0
fastadmin/application/sql/ps_workorder_product_name.sql

@@ -0,0 +1,7 @@
+ALTER TABLE `fa_ps_workorder`
+  ADD COLUMN `product_name` varchar(100) NOT NULL DEFAULT '' COMMENT '产品名称,可不关联产品档案' AFTER `product_id`;
+
+UPDATE `fa_ps_workorder` w
+LEFT JOIN `fa_ps_product` p ON p.id = w.product_id
+SET w.product_name = IFNULL(p.name, '')
+WHERE w.product_name = '' AND w.product_id > 0;

+ 20 - 0
fastadmin/application/sql/ps_workorder_product_name_run.php

@@ -0,0 +1,20 @@
+<?php
+$root = dirname(dirname(__DIR__));
+define('APP_PATH', $root . '/application/');
+require $root . '/thinkphp/base.php';
+\think\App::initCommon();
+
+$prefix = \think\Db::getConfig('prefix');
+$table = $prefix . 'ps_workorder';
+$rows = \think\Db::query("SHOW COLUMNS FROM `{$table}` LIKE 'product_name'");
+if ($rows) {
+    echo $table . ".product_name exists\n";
+} else {
+    \think\Db::execute("ALTER TABLE `{$table}` ADD COLUMN `product_name` varchar(100) NOT NULL DEFAULT '' COMMENT '产品名称,可不关联产品档案' AFTER `product_id`");
+    echo $table . ".product_name added\n";
+}
+
+$updated = \think\Db::execute(
+    "UPDATE `{$table}` w LEFT JOIN `{$prefix}ps_product` p ON p.id = w.product_id SET w.product_name = IFNULL(p.name, '') WHERE w.product_name = '' AND w.product_id > 0"
+);
+echo "backfilled {$updated} rows\n";

+ 177 - 0
fastadmin/public/assets/js/backend/ps/home_block.js

@@ -0,0 +1,177 @@
+define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
+
+    var statKeys = [
+        {value: '', text: '不绑定(只用固定值)'},
+        {value: 'factory_count', text: '入驻厂家'},
+        {value: 'print_today', text: '今日打印量'},
+        {value: 'print_total', text: '累计套打张数'},
+        {value: 'template_count', text: '可用模板'}
+    ];
+
+    function escapeAttr(value) {
+        return String(value == null ? '' : value)
+            .replace(/&/g, '&amp;')
+            .replace(/"/g, '&quot;')
+            .replace(/</g, '&lt;')
+            .replace(/>/g, '&gt;');
+    }
+
+    function parseContent() {
+        var raw = $.trim($('#home-block-content').text() || '{}');
+        try {
+            var data = JSON.parse(raw);
+            return data && typeof data === 'object' ? data : {};
+        } catch (e) {
+            return {};
+        }
+    }
+
+    function rowHtml(type, item, index) {
+        item = item || {};
+        if (type === 'stats') {
+            var options = statKeys.map(function (opt) {
+                var selected = String(item.key || '') === opt.value ? ' selected' : '';
+                return '<option value="' + escapeAttr(opt.value) + '"' + selected + '>' + opt.text + '</option>';
+            }).join('');
+            return '<div class="item-row well well-sm">' +
+                '<div class="row">' +
+                '<div class="col-xs-4"><input class="form-control" name="row[content][items][' + index + '][label]" placeholder="名称" value="' + escapeAttr(item.label) + '"></div>' +
+                '<div class="col-xs-4"><select class="form-control" name="row[content][items][' + index + '][key]">' + options + '</select></div>' +
+                '<div class="col-xs-3"><input class="form-control" name="row[content][items][' + index + '][value]" placeholder="固定值可空" value="' + escapeAttr(item.value) + '"></div>' +
+                '<div class="col-xs-1"><a href="javascript:;" class="btn btn-xs btn-danger btn-remove-item" title="删除"><i class="fa fa-times"></i></a></div>' +
+                '</div></div>';
+        }
+        if (type === 'steps') {
+            return '<div class="item-row well well-sm">' +
+                '<div class="form-group" style="margin-bottom:8px;"><input class="form-control" name="row[content][items][' + index + '][title]" placeholder="步骤标题" value="' + escapeAttr(item.title) + '"></div>' +
+                '<div class="form-group" style="margin-bottom:8px;"><textarea class="form-control" rows="2" name="row[content][items][' + index + '][desc]" placeholder="步骤说明">' + escapeAttr(item.desc) + '</textarea></div>' +
+                '<a href="javascript:;" class="btn btn-xs btn-danger btn-remove-item">删除步骤</a>' +
+                '</div>';
+        }
+        return '<div class="item-row well well-sm">' +
+            '<div class="row" style="margin-bottom:8px;">' +
+            '<div class="col-xs-3"><input class="form-control" name="row[content][items][' + index + '][tag]" placeholder="标签" value="' + escapeAttr(item.tag) + '"></div>' +
+            '<div class="col-xs-5"><input class="form-control" name="row[content][items][' + index + '][title]" placeholder="标题" value="' + escapeAttr(item.title) + '"></div>' +
+            '<div class="col-xs-4"><input class="form-control" name="row[content][items][' + index + '][path]" placeholder="跳转路径" value="' + escapeAttr(item.path) + '"></div>' +
+            '</div>' +
+            '<div class="form-group" style="margin-bottom:8px;"><textarea class="form-control" rows="2" name="row[content][items][' + index + '][desc]" placeholder="说明">' + escapeAttr(item.desc) + '</textarea></div>' +
+            '<a href="javascript:;" class="btn btn-xs btn-danger btn-remove-item">删除卡片</a>' +
+            '</div>';
+    }
+
+    function fillItems(type, items) {
+        var $box = $('.item-rows[data-type="' + type + '"]');
+        if (!$box.length) {
+            return;
+        }
+        $box.empty();
+        (items || []).forEach(function (item, index) {
+            $box.append(rowHtml(type, item, index));
+        });
+    }
+
+    function reindex($box) {
+        $box.children('.item-row').each(function (index) {
+            $(this).find('[name]').each(function () {
+                this.name = this.name.replace(/items\]\[\d+\]/, 'items][' + index + ']');
+            });
+        });
+    }
+
+    function syncTypePanel() {
+        var type = $('#c-type').val() || 'custom';
+        $('.type-panel').each(function () {
+            var match = $(this).data('type') === type;
+            $(this).toggle(match);
+            $(this).find(':input').prop('disabled', !match);
+        });
+    }
+
+    var Controller = {
+        index: function () {
+            Table.api.init({
+                extend: {
+                    index_url: 'ps/home_block/index' + location.search,
+                    add_url: 'ps/home_block/add',
+                    edit_url: 'ps/home_block/edit',
+                    del_url: 'ps/home_block/del',
+                    multi_url: 'ps/home_block/multi',
+                    table: 'ps_home_block',
+                }
+            });
+
+            $('.btn-add, .btn-edit').data('area', ['880px', '92%']);
+            var table = $("#table");
+            table.bootstrapTable({
+                url: $.fn.bootstrapTable.defaults.extend.index_url,
+                pk: 'id',
+                sortName: 'weigh',
+                sortOrder: 'desc',
+                columns: [
+                    [
+                        {checkbox: true},
+                        {field: 'id', title: __('Id')},
+                        {
+                            field: 'type',
+                            title: __('Type'),
+                            searchList: {
+                                "hero": __('Hero'),
+                                "stats": __('Stats'),
+                                "steps": __('Steps'),
+                                "features": __('Features'),
+                                "cta": __('Cta'),
+                                "custom": __('Custom')
+                            },
+                            formatter: Table.api.formatter.normal
+                        },
+                        {field: 'name', title: __('Name'), operate: 'LIKE'},
+                        {field: 'title', title: __('Title'), operate: 'LIKE'},
+                        {field: 'kicker', title: __('Kicker'), operate: 'LIKE'},
+                        {field: 'weigh', title: __('Weigh'), operate: false},
+                        {field: 'status', title: __('Status'), searchList: {"normal": __('Normal'), "hidden": __('Hidden')}, formatter: Table.api.formatter.status},
+                        {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate}
+                    ]
+                ]
+            });
+            Table.api.bindevent(table);
+            table.data('area', ['880px', '92%']);
+        },
+        add: function () {
+            Controller.api.bindevent();
+        },
+        edit: function () {
+            Controller.api.bindevent();
+        },
+        api: {
+            bindevent: function () {
+                var content = parseContent();
+                var currentType = $('#c-type').val();
+                if (currentType === 'stats' || currentType === 'steps' || currentType === 'features') {
+                    var items = Array.isArray(content.items) && content.items.length ? content.items : [{}];
+                    fillItems(currentType, items);
+                }
+                $(document).on('change', '#c-type', function () {
+                    syncTypePanel();
+                    var type = $(this).val();
+                    var $box = $('.item-rows[data-type="' + type + '"]');
+                    if ($box.length && !$box.children().length && (type === 'stats' || type === 'steps' || type === 'features')) {
+                        fillItems(type, [{}]);
+                    }
+                });
+                $(document).on('click', '.btn-add-item', function () {
+                    var type = $(this).data('type');
+                    var $box = $('.item-rows[data-type="' + type + '"]');
+                    $box.append(rowHtml(type, {}, $box.children().length));
+                });
+                $(document).on('click', '.btn-remove-item', function () {
+                    var $box = $(this).closest('.item-rows');
+                    $(this).closest('.item-row').remove();
+                    reindex($box);
+                });
+                syncTypePanel();
+                Form.api.bindevent($("form[role=form]"));
+            }
+        }
+    };
+    return Controller;
+});

+ 1 - 1
vue/public/platform-config.json

@@ -1,6 +1,6 @@
 {
   "Version": "6.2.0",
-  "Title": "生产流程单工厂端",
+  "Title": "智印流程单",
   "FixedHeader": true,
   "HiddenSideBar": false,
   "MultiTagsCache": false,

+ 17 - 0
vue/src/api/ps.ts

@@ -12,12 +12,29 @@ export const profile = () => http.request<any>("get", "/user/index");
 
 export const factoryInfo = () => http.request<any>("get", "/ps/factory/index");
 
+export const factoryBoard = () => http.request<any>("get", "/ps/factory/board");
+
+export const homeBlocks = () => http.request<any>("get", "/ps/home/index");
+
 export const factoryResetApiKey = () =>
   http.request<any>("post", "/ps/factory/reset_api_key");
 
 export const factorySave = (data: Record<string, unknown>) =>
   http.request<any>("post", "/ps/factory/edit", { data });
 
+export const factoryMembers = () =>
+  http.request<any>("get", "/ps/factory/members");
+
+export const factoryMemberSave = (userId: number, perms: string[]) =>
+  http.request<any>("post", "/ps/factory/members_save", {
+    data: { user_id: userId, perms }
+  });
+
+export const factoryMemberRemove = (userId: number) =>
+  http.request<any>("post", "/ps/factory/members_remove", {
+    data: { user_id: userId }
+  });
+
 export const productList = (params?: object) =>
   http.request<any>("get", "/ps/product/index", { params });
 

+ 1 - 1
vue/src/api/user.ts

@@ -47,7 +47,7 @@ export const getLogin = async (data?: any): Promise<UserResult> => {
   return mapUserinfo(raw);
 };
 
-/** 注册开厂或加入工厂 */
+/** 注册工厂或加入工厂 */
 export const getRegister = async (data?: object): Promise<UserResult> => {
   const raw = await registerApi(data as Record<string, unknown>);
   return mapUserinfo(raw);

+ 0 - 3
vue/src/layout/components/lay-navbar/index.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { useNav } from "@/layout/hooks/useNav";
 import LaySearch from "../lay-search/index.vue";
-import LayNotice from "../lay-notice/index.vue";
 import LayNavMix from "../lay-sidebar/NavMix.vue";
 import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
 import LaySidebarBreadCrumb from "../lay-sidebar/components/SidebarBreadCrumb.vue";
@@ -44,8 +43,6 @@ const {
       <LaySearch id="header-search" />
       <!-- 全屏 -->
       <LaySidebarFullScreen id="full-screen" />
-      <!-- 消息通知 -->
-      <LayNotice id="header-notice" />
       <!-- 退出登录 -->
       <el-dropdown trigger="click">
         <span class="el-dropdown-link navbar-bg-hover select-none">

+ 0 - 3
vue/src/layout/components/lay-sidebar/NavHorizontal.vue

@@ -2,7 +2,6 @@
 import { emitter } from "@/utils/mitt";
 import { useNav } from "@/layout/hooks/useNav";
 import LaySearch from "../lay-search/index.vue";
-import LayNotice from "../lay-notice/index.vue";
 import { responsiveStorageNameSpace } from "@/config";
 import { ref, nextTick, computed, onMounted } from "vue";
 import { storageLocal, isAllEmpty } from "@pureadmin/utils";
@@ -75,8 +74,6 @@ onMounted(() => {
       <LaySearch id="header-search" />
       <!-- 全屏 -->
       <LaySidebarFullScreen id="full-screen" />
-      <!-- 消息通知 -->
-      <LayNotice id="header-notice" />
       <!-- 退出登录 -->
       <el-dropdown trigger="click">
         <span class="el-dropdown-link navbar-bg-hover">

+ 0 - 3
vue/src/layout/components/lay-sidebar/NavMix.vue

@@ -2,7 +2,6 @@
 import { isAllEmpty } from "@pureadmin/utils";
 import { useNav } from "@/layout/hooks/useNav";
 import LaySearch from "../lay-search/index.vue";
-import LayNotice from "../lay-notice/index.vue";
 import { ref, toRaw, watch, onMounted, nextTick } from "vue";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { getParentPaths, findRouteByPath } from "@/router/utils";
@@ -95,8 +94,6 @@ watch(
       <LaySearch id="header-search" />
       <!-- 全屏 -->
       <LaySidebarFullScreen id="full-screen" />
-      <!-- 消息通知 -->
-      <LayNotice id="header-notice" />
       <!-- 退出登录 -->
       <el-dropdown trigger="click">
         <span class="el-dropdown-link navbar-bg-hover select-none">

+ 3 - 2
vue/src/layout/hooks/useNav.ts

@@ -12,6 +12,7 @@ import { useAppStoreHook } from "@/store/modules/app";
 import { useUserStoreHook } from "@/store/modules/user";
 import { useGlobal, isAllEmpty } from "@pureadmin/utils";
 import { usePermissionStoreHook } from "@/store/modules/permission";
+import { useSiteStoreHook } from "@/store/modules/site";
 import ExitFullscreen from "~icons/ri/fullscreen-exit-fill";
 import Fullscreen from "~icons/ri/fullscreen-fill";
 
@@ -69,12 +70,12 @@ export function useNav() {
   });
 
   const title = computed(() => {
-    return $config.Title;
+    return useSiteStoreHook().title || $config.Title;
   });
 
   /** 动态title */
   function changeTitle(meta: routeMetaType) {
-    const Title = getConfig().Title;
+    const Title = useSiteStoreHook().title || getConfig().Title;
     if (Title) document.title = `${meta.title} | ${Title}`;
     else document.title = meta.title;
   }

+ 2 - 0
vue/src/main.ts

@@ -57,6 +57,8 @@ getPlatformConfig(app).then(async config => {
   app.use(router);
   await router.isReady();
   injectResponsiveStorage(app, config);
+  const { useSiteStoreHook } = await import("@/store/modules/site");
+  useSiteStoreHook().loadBrand();
   app.use(MotionPlugin).use(useElementPlus).use(Table);
   // .use(PureDescriptions)
   // .use(useEcharts);

+ 23 - 7
vue/src/router/index.ts

@@ -1,6 +1,5 @@
 // import "@/utils/sso";
 import Cookies from "js-cookie";
-import { getConfig } from "@/config";
 import NProgress from "@/utils/progress";
 import { buildHierarchyTree } from "@/utils/tree";
 import remainingRouter from "./modules/remaining";
@@ -36,7 +35,9 @@ import {
   removeToken,
   multipleTabsKey
 } from "@/utils/auth";
+import { hasPerms } from "@/utils/auth";
 import { useUserStoreHook } from "@/store/modules/user";
+import { getSiteTitle } from "@/store/modules/site";
 
 /** 自动导入全部静态路由,无需再手动引入!匹配 src/router/modules 目录(任何嵌套级别)中具有 .ts 扩展名的所有文件,除了 remaining.ts 文件
  * 如何匹配所有文件请看:https://github.com/mrmlnc/fast-glob#basic-syntax
@@ -115,8 +116,10 @@ export function resetRouter() {
   resetLoadedPaths();
 }
 
-/** 路由白名单 */
-const whiteList = ["/login", "/register"];
+/** 未登录可访问 */
+const whiteList = ["/", "/login", "/register"];
+/** 已登录不应再进入 */
+const authPages = ["/login", "/register"];
 
 const { VITE_HIDE_HOME } = import.meta.env;
 
@@ -139,14 +142,18 @@ router.beforeEach((to: ToRouteType, _from, next) => {
   if (!externalLink) {
     to.matched.some(item => {
       if (!item.meta.title) return "";
-      const Title = getConfig().Title;
+      const Title = getSiteTitle();
       if (Title) document.title = `${item.meta.title} | ${Title}`;
       else document.title = item.meta.title as string;
     });
   }
-  /** 如果已经登录并存在登录信息后不能跳转到路由白名单,而是继续保持在当前页面 */
+  /** 已登录访问登录/注册时回到工作台;公开首页进工作台 */
   function toCorrectRoute() {
-    whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
+    if (authPages.includes(to.path) || to.path === "/") {
+      next("/dashboard");
+      return;
+    }
+    next();
   }
   if (Cookies.get(multipleTabsKey) && userInfo) {
     if (!whiteList.includes(to.path)) {
@@ -155,6 +162,15 @@ router.beforeEach((to: ToRouteType, _from, next) => {
     // 无权限跳转403页面
     if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
       next({ path: "/error/403" });
+      return;
+    }
+    if (
+      Array.isArray(to.meta?.perms) &&
+      to.meta.perms.length > 0 &&
+      !to.meta.perms.some((item: string) => hasPerms(item))
+    ) {
+      next({ path: "/error/403" });
+      return;
     }
     // 开启隐藏首页后在浏览器地址栏手动输入首页welcome路由则跳转到404页面
     if (VITE_HIDE_HOME === "true" && to.fullPath === "/welcome") {
@@ -172,7 +188,7 @@ router.beforeEach((to: ToRouteType, _from, next) => {
       // 刷新
       if (
         usePermissionStoreHook().wholeMenus.length === 0 &&
-        to.path !== "/login"
+        !authPages.includes(to.path)
       ) {
         initRouter().then((router: Router) => {
           if (!useMultiTagsStoreHook().getMultiTagsCache) {

+ 2 - 2
vue/src/router/modules/home.ts

@@ -2,7 +2,7 @@ const { VITE_HIDE_HOME } = import.meta.env;
 const Layout = () => import("@/layout/index.vue");
 
 export default {
-  path: "/",
+  path: "/workspace",
   name: "Home",
   component: Layout,
   redirect: "/dashboard",
@@ -15,7 +15,7 @@ export default {
     {
       path: "/dashboard",
       name: "Welcome",
-      component: () => import("@/views/welcome/index.vue"),
+      component: () => import("@/views/welcome/dashboard.vue"),
       meta: {
         title: "工作台",
         showLink: VITE_HIDE_HOME === "true" ? false : true

+ 2 - 1
vue/src/router/modules/market.ts

@@ -12,7 +12,8 @@ export default {
       name: "MarketList",
       component: () => import("@/views/market/List.vue"),
       meta: {
-        title: "模板商城"
+        title: "模板商城",
+        perms: ["market.view"]
       }
     }
   ]

+ 21 - 0
vue/src/router/modules/members.ts

@@ -0,0 +1,21 @@
+export default {
+  path: "/member",
+  redirect: "/factory/members",
+  meta: {
+    icon: "ep/user",
+    title: "成员权限",
+    rank: 6,
+    roles: ["owner"]
+  },
+  children: [
+    {
+      path: "/factory/members",
+      name: "FactoryMembers",
+      component: () => import("@/views/factory/Members.vue"),
+      meta: {
+        title: "成员权限",
+        roles: ["owner"]
+      }
+    }
+  ]
+} satisfies RouteConfigsTable;

+ 3 - 2
vue/src/router/modules/print.ts

@@ -4,7 +4,7 @@ export default {
   meta: {
     icon: "ep/printer",
     title: "打印记录",
-    rank: 6
+    rank: 7
   },
   children: [
     {
@@ -12,7 +12,8 @@ export default {
       name: "PrintHistory",
       component: () => import("@/views/print/History.vue"),
       meta: {
-        title: "打印记录"
+        title: "打印记录",
+        perms: ["print.view"]
       }
     }
   ]

+ 4 - 2
vue/src/router/modules/product.ts

@@ -12,7 +12,8 @@ export default {
       name: "ProductList",
       component: () => import("@/views/product/List.vue"),
       meta: {
-        title: "产品工艺"
+        title: "产品工艺",
+        perms: ["product.view"]
       }
     },
     {
@@ -21,7 +22,8 @@ export default {
       component: () => import("@/views/product/Edit.vue"),
       meta: {
         title: "编辑产品",
-        showLink: false
+        showLink: false,
+        perms: ["product.edit"]
       }
     }
   ]

+ 9 - 0
vue/src/router/modules/remaining.ts

@@ -1,6 +1,15 @@
 const Layout = () => import("@/layout/index.vue");
 
 export default [
+  {
+    path: "/",
+    name: "PublicHome",
+    component: () => import("@/views/welcome/index.vue"),
+    meta: {
+      title: "首页",
+      showLink: false
+    }
+  },
   {
     path: "/login",
     name: "Login",

+ 2 - 1
vue/src/router/modules/template.ts

@@ -12,7 +12,8 @@ export default {
       name: "TemplateList",
       component: () => import("@/views/template/List.vue"),
       meta: {
-        title: "本厂模板"
+        title: "本厂模板",
+        perms: ["template.view"]
       }
     }
   ]

+ 4 - 2
vue/src/router/modules/workorder.ts

@@ -12,7 +12,8 @@ export default {
       name: "WorkorderList",
       component: () => import("@/views/workorder/List.vue"),
       meta: {
-        title: "工单套打"
+        title: "工单套打",
+        perms: ["workorder.view"]
       }
     },
     {
@@ -21,7 +22,8 @@ export default {
       component: () => import("@/views/workorder/Edit.vue"),
       meta: {
         title: "编辑工单",
-        showLink: false
+        showLink: false,
+        perms: ["workorder.edit"]
       }
     }
   ]

+ 43 - 20
vue/src/router/utils.ts

@@ -85,15 +85,30 @@ function isOneOfArray(a: Array<string>, b: Array<string>) {
 function filterNoPermissionTree(data: RouteComponent[]) {
   const currentRoles =
     storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [];
-  const newTree = cloneDeep(data).filter((v: any) =>
-    isOneOfArray(v.meta?.roles, currentRoles)
-  );
+  const currentPerms =
+    storageLocal().getItem<DataInfo<number>>(userKey)?.permissions ?? [];
+  const newTree = cloneDeep(data).filter((v: any) => {
+    if (!isOneOfArray(v.meta?.roles, currentRoles)) {
+      return false;
+    }
+    return hasMenuPerms(v.meta?.perms, currentPerms);
+  });
   newTree.forEach(
     (v: any) => v.children && (v.children = filterNoPermissionTree(v.children))
   );
   return filterChildrenTree(newTree);
 }
 
+function hasMenuPerms(required: string[] | undefined, current: string[]) {
+  if (!Array.isArray(required) || required.length === 0) {
+    return true;
+  }
+  if (current?.length === 1 && current[0] === "*:*:*") {
+    return true;
+  }
+  return required.some(item => current?.includes(item));
+}
+
 /** 通过指定 `key` 获取父级路径集合,默认 `key` 为 `path` */
 function getParentPaths(value: string, routes: RouteRecordRaw[], key = "path") {
   // 深度遍历查找
@@ -176,10 +191,11 @@ function handleAsyncRoutes(routeList) {
           if (!router.hasRoute(v?.name)) router.addRoute(v);
           const flattenRouters: any = router
             .getRoutes()
-            .find(n => n.path === "/");
-          // 保持router.options.routes[0].children与path为"/"的children一致,防止数据不一致导致异常
-          flattenRouters.children = router.options.routes[0].children;
-          router.addRoute(flattenRouters);
+            .find(n => n.path === "/workspace" || n.name === "Home");
+          if (flattenRouters) {
+            flattenRouters.children = router.options.routes[0].children;
+            router.addRoute(flattenRouters);
+          }
         }
       }
     );
@@ -250,22 +266,29 @@ function formatFlatteningRoutes(routesList: RouteRecordRaw[]) {
  * @param routesList 处理后的一维路由菜单数组
  * @returns 返回将一维数组重新处理成规定路由的格式
  */
+function isLayoutRoot(route: RouteRecordRaw) {
+  return (
+    route.path === "/" || route.path === "/workspace" || route.name === "Home"
+  );
+}
+
 function formatTwoStageRoutes(routesList: RouteRecordRaw[]) {
   if (routesList.length === 0) return routesList;
-  const newRoutesList: RouteRecordRaw[] = [];
-  routesList.forEach((v: RouteRecordRaw) => {
-    if (v.path === "/") {
-      newRoutesList.push({
-        component: v.component,
-        name: v.name,
-        path: v.path,
-        redirect: v.redirect,
-        meta: v.meta,
-        children: []
-      });
-    } else {
-      newRoutesList[0]?.children.push({ ...v });
+  const root = routesList.find(v => isLayoutRoot(v));
+  if (!root) return routesList;
+  const newRoutesList: RouteRecordRaw[] = [
+    {
+      component: root.component,
+      name: root.name,
+      path: root.path,
+      redirect: root.redirect,
+      meta: root.meta,
+      children: []
     }
+  ];
+  routesList.forEach((v: RouteRecordRaw) => {
+    if (isLayoutRoot(v)) return;
+    newRoutesList[0].children.push({ ...v });
   });
   return newRoutesList;
 }

+ 64 - 0
vue/src/store/modules/site.ts

@@ -0,0 +1,64 @@
+import { defineStore } from "pinia";
+import { getConfig } from "@/config";
+import { homeBlocks } from "@/api/ps";
+import { store } from "../utils";
+import { storageLocal } from "@pureadmin/utils";
+import { useSettingStoreHook } from "./settings";
+
+const siteKey = "site-brand";
+
+function fallbackTitle() {
+  return String(getConfig()?.Title || "智印流程单");
+}
+
+export function brandFromBlocks(list: unknown): string {
+  const rows = Array.isArray(list) ? list : [];
+  const hero = rows.find((item: any) => item?.type === "hero") as any;
+  const name = String(hero?.content?.brand || hero?.title || "").trim();
+  return name || fallbackTitle();
+}
+
+export const useSiteStore = defineStore("ps-site", {
+  state: () => ({
+    brand: storageLocal().getItem<{ brand?: string }>(siteKey)?.brand || fallbackTitle(),
+    loaded: false
+  }),
+  getters: {
+    title: state => state.brand || fallbackTitle()
+  },
+  actions: {
+    applyFromBlocks(list: unknown) {
+      const brand = brandFromBlocks(list);
+      this.brand = brand;
+      this.loaded = true;
+      storageLocal().setItem(siteKey, { brand });
+      useSettingStoreHook().changeSetting({ key: "title", value: brand });
+      if (typeof document !== "undefined" && document.title) {
+        const parts = document.title.split(" | ");
+        if (parts.length === 2) {
+          document.title = `${parts[0]} | ${brand}`;
+        }
+      }
+    },
+    async loadBrand() {
+      try {
+        const list = await homeBlocks();
+        this.applyFromBlocks(list);
+      } catch (e) {
+        if (!this.brand) {
+          this.brand = fallbackTitle();
+        }
+      } finally {
+        this.loaded = true;
+      }
+    }
+  }
+});
+
+export function useSiteStoreHook() {
+  return useSiteStore(store);
+}
+
+export function getSiteTitle() {
+  return useSiteStoreHook().title || fallbackTitle();
+}

+ 23 - 4
vue/src/store/modules/user.ts

@@ -39,7 +39,8 @@ export const useUserStore = defineStore("pure-user", {
     loginDay: 7,
     loggingOut: false,
     factory: storageLocal().getItem(factoryKey)?.factory ?? null,
-    role: storageLocal().getItem(factoryKey)?.role ?? ""
+    role: storageLocal().getItem(factoryKey)?.role ?? "",
+    factoryReady: false
   }),
   getters: {
     isOwner: state => state.role === "owner"
@@ -101,14 +102,31 @@ export const useUserStore = defineStore("pure-user", {
       const data = await factoryInfo();
       this.factory = data.factory;
       this.role = data.role;
-      this.SET_ROLES([data.role === "owner" ? "owner" : "operator"]);
+      const roleName = data.role === "owner" ? "owner" : "operator";
+      const perms =
+        data.role === "owner"
+          ? ["*:*:*"]
+          : Array.isArray(data.perms) && data.perms.length
+            ? data.perms
+            : [];
+      this.SET_ROLES([roleName]);
+      this.SET_PERMS(perms);
       storageLocal().setItem(factoryKey, {
         factory: data.factory,
         role: data.role
       });
+      const userInfo = storageLocal().getItem<DataInfo<number>>(userKey) || {};
+      storageLocal().setItem(userKey, {
+        ...userInfo,
+        roles: [roleName],
+        permissions: perms
+      });
+      const { usePermissionStoreHook } = await import("./permission");
+      usePermissionStoreHook().handleWholeMenus([]);
+      this.factoryReady = true;
     },
     async ensureFactory() {
-      if (this.factory) return this.factory;
+      if (this.factoryReady && this.factory) return this.factory;
       await this.loadFactory();
       return this.factory;
     },
@@ -127,11 +145,12 @@ export const useUserStore = defineStore("pure-user", {
       this.permissions = [];
       this.factory = null;
       this.role = "";
+      this.factoryReady = false;
       storageLocal().removeItem(factoryKey);
       removeToken();
       useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
       resetRouter();
-      router.push("/login").finally(() => {
+      router.push("/").finally(() => {
         this.loggingOut = false;
       });
     },

+ 1 - 0
vue/src/store/types.ts

@@ -47,4 +47,5 @@ export type userType = {
   loggingOut?: boolean;
   factory?: any;
   role?: string;
+  factoryReady?: boolean;
 };

+ 303 - 62
vue/src/style/login.css

@@ -1,96 +1,337 @@
-.wave {
-  position: fixed;
-  height: 100%;
-  width: 80%;
-  left: 0;
-  bottom: 0;
-  z-index: -1;
+.auth-page {
+  position: relative;
+  display: grid;
+  grid-template-columns: minmax(320px, 1.05fr) minmax(420px, 0.95fr);
+  min-height: 100vh;
+  background: #fff;
+  color: #0f172a;
 }
 
-.login-container {
-  width: 100vw;
-  height: 100vh;
-  max-width: 100%;
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-gap: 18rem;
-  padding: 0 2rem;
+.auth-theme {
+  position: absolute;
+  top: 20px;
+  right: 24px;
+  z-index: 5;
 }
 
-.img {
+.auth-side {
   display: flex;
-  justify-content: flex-end;
-  align-items: center;
+  flex-direction: column;
+  justify-content: center;
+  padding: 72px 80px;
+  background:
+    radial-gradient(720px 420px at 12% 18%, rgba(79, 110, 247, 0.16), transparent 58%),
+    linear-gradient(180deg, #eef2ff 0%, #f8fafc 48%, #fff 100%);
+}
+
+.auth-logo {
+  display: inline-flex;
+  width: fit-content;
+  margin-bottom: 36px;
+  color: #0f172a;
+  font-size: 18px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+}
+
+.auth-kicker {
+  margin: 0 0 16px;
+  color: #4f6ef7;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.16em;
+}
+
+.auth-side h1 {
+  max-width: 9em;
+  margin: 0;
+  font-size: clamp(32px, 3.6vw, 48px);
+  font-weight: 740;
+  line-height: 1.22;
+  letter-spacing: -0.03em;
+}
+
+.auth-points {
+  max-width: 28em;
+  margin: 36px 0 0;
+  padding: 0;
+  list-style: none;
+}
+
+.auth-points li {
+  position: relative;
+  padding: 10px 0 10px 22px;
+  color: #64748b;
+  font-size: 15px;
+  line-height: 1.7;
+}
+
+.auth-points li::before {
+  content: "";
+  position: absolute;
+  top: 18px;
+  left: 0;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #4f6ef7;
 }
 
-.img img {
-  width: 500px;
+.auth-home {
+  margin-top: 48px;
+  color: #64748b;
+  font-size: 14px;
 }
 
-.login-box {
+.auth-home a {
+  color: #4f6ef7;
+  font-weight: 600;
+}
+
+.auth-main {
   display: flex;
   align-items: center;
+  justify-content: center;
+  padding: 72px 48px;
+}
+
+.auth-card {
+  width: min(420px, 100%);
+}
+
+.auth-card-wide {
+  width: min(460px, 100%);
+}
+
+.auth-card h2 {
+  margin: 0;
+  font-size: 28px;
+  font-weight: 720;
+  letter-spacing: -0.03em;
+}
+
+.auth-sub {
+  margin: 10px 0 28px;
+  color: #64748b;
+  font-size: 15px;
+  line-height: 1.7;
+}
+
+.auth-tabs {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 6px;
+  margin-bottom: 28px;
+  padding: 5px;
+  border-radius: 999px;
+  background: #f1f5f9;
+}
+
+.auth-tab {
+  height: 40px;
+  border: 0;
+  border-radius: 999px;
+  background: transparent;
+  color: #64748b;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+.auth-tab.on {
+  background: #fff;
+  color: #0f172a;
+  box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
+}
+
+.auth-page .el-form-item {
+  margin-bottom: 18px;
+}
+
+.auth-page .el-form-item__label {
+  color: #475569;
+  font-weight: 600;
+  line-height: 1.2;
+  margin-bottom: 8px !important;
+}
+
+.auth-page .el-input__wrapper {
+  min-height: 50px;
+  padding: 4px 16px;
+  border-radius: 14px;
+  background: #f6f8fc;
+  box-shadow: 0 0 0 1px #e6ebf3 inset !important;
+  transition: background 0.16s ease, box-shadow 0.16s ease;
+}
+
+.auth-page .el-input__wrapper:hover {
+  background: #fff;
+  box-shadow: 0 0 0 1px #c9d4ea inset !important;
+}
+
+.auth-page .el-input__wrapper.is-focus {
+  background: #fff;
+  box-shadow: 0 0 0 2px #4f6ef7 inset !important;
+}
+
+.auth-page .el-input__inner {
+  height: 42px;
+  color: #0f172a;
+  font-size: 15px;
+}
+
+.auth-page .el-input__inner::placeholder {
+  color: #94a3b8;
+}
+
+.auth-page .el-input__prefix,
+.auth-page .el-input__suffix {
+  color: #94a3b8;
+}
+
+.auth-page .el-form-item.is-error .el-input__wrapper {
+  box-shadow: 0 0 0 1px #f43f5e inset !important;
+}
+
+.auth-page .el-button.auth-submit {
+  width: 100%;
+  height: 50px;
+  margin-top: 8px;
+  border: 0;
+  border-radius: 999px;
+  --el-button-bg-color: #4f6ef7;
+  --el-button-border-color: #4f6ef7;
+  --el-button-hover-bg-color: #3d5de8;
+  --el-button-hover-border-color: #3d5de8;
+  --el-button-active-bg-color: #3554d6;
+  --el-button-active-border-color: #3554d6;
+  font-size: 15px;
+  font-weight: 650;
+  box-shadow: 0 10px 24px rgba(79, 110, 247, 0.24);
+}
+
+.auth-extra {
+  margin-top: 28px;
+  padding-top: 22px;
+  border-top: 1px solid #eef1f6;
   text-align: center;
-  overflow: hidden;
 }
 
-.login-form {
-  width: 360px;
+.auth-extra-title {
+  margin: 0 0 14px;
+  color: #0f172a;
+  font-size: 14px;
+  font-weight: 650;
 }
 
-.avatar {
-  width: 350px;
-  height: 80px;
+.auth-extra-btns {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
 }
 
-.login-form h2 {
-  text-transform: uppercase;
-  margin: 15px 0;
-  color: #999;
-  font:
-    bold 200% Consolas,
-    Monaco,
-    monospace;
+.auth-page .el-button.auth-extra-btn,
+.auth-page .el-button.auth-ghost {
+  height: 44px;
+  margin: 0;
+  border: 1px solid #e2e8f0;
+  border-radius: 999px;
+  background: #fff;
+  color: #0f172a;
+  font-weight: 600;
+  --el-button-bg-color: #fff;
+  --el-button-border-color: #e2e8f0;
+  --el-button-hover-bg-color: #f8fafc;
+  --el-button-hover-border-color: #c9d4ea;
+  --el-button-hover-text-color: #0f172a;
+  --el-button-text-color: #0f172a;
 }
 
-@media screen and (max-width: 1180px) {
-  .login-container {
-    grid-gap: 9rem;
-  }
+.auth-extra-hint,
+.auth-link {
+  margin: 14px 0 0;
+  color: #94a3b8;
+  font-size: 13px;
+  line-height: 1.6;
+}
 
-  .login-form {
-    width: 290px;
-  }
+.auth-link a {
+  color: #4f6ef7;
+  font-weight: 600;
+}
 
-  .login-form h2 {
-    font-size: 2.4rem;
-    margin: 8px 0;
-  }
+html.dark .auth-page {
+  background: #0f172a;
+  color: #e2e8f0;
+}
 
-  .img img {
-    width: 360px;
-  }
+html.dark .auth-side {
+  background:
+    radial-gradient(720px 420px at 12% 18%, rgba(79, 110, 247, 0.22), transparent 58%),
+    linear-gradient(180deg, #111827 0%, #0f172a 100%);
+}
 
-  .avatar {
-    width: 280px;
-    height: 80px;
-  }
+html.dark .auth-logo,
+html.dark .auth-side h1,
+html.dark .auth-card h2,
+html.dark .auth-extra-title {
+  color: #f8fafc;
+}
+
+html.dark .auth-points li,
+html.dark .auth-sub,
+html.dark .auth-home,
+html.dark .auth-extra-hint,
+html.dark .auth-link {
+  color: #94a3b8;
+}
+
+html.dark .auth-tabs {
+  background: #1e293b;
+}
+
+html.dark .auth-tab.on {
+  background: #0f172a;
+  color: #f8fafc;
+}
+
+html.dark .auth-page .el-input__wrapper {
+  background: #1e293b;
+  box-shadow: 0 0 0 1px #334155 inset !important;
+}
+
+html.dark .auth-page .el-input__wrapper:hover,
+html.dark .auth-page .el-input__wrapper.is-focus {
+  background: #0f172a;
+}
+
+html.dark .auth-page .el-input__inner {
+  color: #e2e8f0;
+}
+
+html.dark .auth-page .el-button.auth-extra-btn,
+html.dark .auth-page .el-button.auth-ghost {
+  background: #0f172a;
+  border-color: #334155;
+  color: #e2e8f0;
+  --el-button-bg-color: #0f172a;
+  --el-button-border-color: #334155;
+  --el-button-text-color: #e2e8f0;
+  --el-button-hover-bg-color: #1e293b;
+  --el-button-hover-text-color: #f8fafc;
 }
 
 @media screen and (max-width: 968px) {
-  .wave {
-    display: none;
+  .auth-page {
+    grid-template-columns: 1fr;
   }
 
-  .img {
+  .auth-side {
     display: none;
   }
 
-  .login-container {
-    grid-template-columns: 1fr;
-  }
-
-  .login-box {
-    justify-content: center;
+  .auth-main {
+    min-height: 100vh;
+    padding: 88px 24px 40px;
   }
 }

+ 8 - 2
vue/src/utils/http/index.ts

@@ -14,7 +14,13 @@ import { ElMessage } from "element-plus";
 import { getToken } from "@/utils/auth";
 import { useUserStoreHook } from "@/store/modules/user";
 
-const authFree = ["/user/login", "/user/register", "/user/logout"];
+const authFree = [
+  "/user/login",
+  "/user/register",
+  "/user/logout",
+  "/ps/factory/board",
+  "/ps/home/index"
+];
 
 function isAuthFree(url?: string) {
   const path = String(url || "");
@@ -97,7 +103,7 @@ class PureHttp {
           }
         }
         /** FastAdmin 登录/注册不带 token */
-        const whiteList = ["/user/login", "/user/register"];
+        const whiteList = ["/user/login", "/user/register", "/ps/home/index"];
         return whiteList.some(url => String(config.url).endsWith(url))
           ? config
           : new Promise(resolve => {

+ 30 - 0
vue/src/utils/print.ts

@@ -29,3 +29,33 @@ export function openPrint(
 export function closePrint() {
   printState.visible = false;
 }
+
+const printBizTypeText: Record<string, string> = {
+  product: "产品",
+  workorder: "工单"
+};
+
+const printModeText: Record<string, string> = {
+  preview: "仅预览",
+  print: "正式打印"
+};
+
+const printStatusText: Record<string, string> = {
+  previewed: "已预览",
+  printed: "已打印",
+  canceled: "已取消"
+};
+
+export function printFieldText(
+  kind: "biz_type" | "lodop_mode" | "status",
+  value: string
+) {
+  const raw = String(value || "");
+  const map =
+    kind === "biz_type"
+      ? printBizTypeText
+      : kind === "lodop_mode"
+        ? printModeText
+        : printStatusText;
+  return map[raw] || raw || "-";
+}

+ 448 - 0
vue/src/views/factory/Members.vue

@@ -0,0 +1,448 @@
+<template>
+  <div class="page-card members-page" v-loading="loading">
+    <div class="head">
+      <div>
+        <h2>成员权限</h2>
+        <p>
+          限制本厂操作员能使用的菜单和操作,对方刷新后生效。管理员始终拥有全部权限。
+        </p>
+      </div>
+      <div class="head-actions">
+        <el-button @click="load">刷新</el-button>
+      </div>
+    </div>
+
+    <div v-if="factory.invite_code" class="invite">
+      <span>邀请码</span>
+      <el-tag>{{ factory.invite_code }}</el-tag>
+      <el-button text type="primary" @click="copyInvite">复制</el-button>
+      <em>发给同事,注册时填写即可加入本厂。</em>
+    </div>
+
+    <div class="layout">
+      <aside class="list">
+        <div class="list-title">本厂成员 {{ members.length }}</div>
+        <button
+          v-for="row in members"
+          :key="row.id"
+          type="button"
+          class="person"
+          :class="{ active: current && current.id === row.id, dirty: isDirty(row) }"
+          @click="selectMember(row)"
+        >
+          <span class="avatar">{{ initial(row) }}</span>
+          <span class="meta">
+            <b>{{ row.nickname || row.username }}</b>
+            <i>{{ row.is_owner ? "管理员" : permSummary(row) }}</i>
+          </span>
+          <el-tag v-if="row.is_owner" size="small" type="warning">管理员</el-tag>
+          <el-tag v-else-if="isDirty(row)" size="small">未保存</el-tag>
+        </button>
+      </aside>
+
+      <section v-if="current" class="detail">
+        <div class="detail-head">
+          <div>
+            <h3>{{ current.nickname || current.username }}</h3>
+            <p>
+              {{ current.username }}
+              <span v-if="current.logintime"> · 最近登录 {{ formatTime(current.logintime) }}</span>
+            </p>
+          </div>
+          <div v-if="!current.is_owner" class="detail-actions">
+            <el-button @click="setAll(true)">全部开通</el-button>
+            <el-button @click="setAll(false)">全部关闭</el-button>
+            <el-button type="danger" plain :loading="current.removing" @click="removeMember(current)">移出本厂</el-button>
+            <el-button type="primary" :loading="current.saving" :disabled="!isDirty(current)" @click="saveMember(current)">保存权限</el-button>
+          </div>
+        </div>
+
+        <el-alert
+          v-if="current.is_owner"
+          type="success"
+          :closable="false"
+          show-icon
+          title="管理员拥有全部功能,不能在这里限制。"
+        />
+
+        <div v-else class="groups">
+          <article v-for="group in permGroups" :key="group.name" class="group">
+            <div class="group-head">
+              <el-checkbox
+                :model-value="groupChecked(group)"
+                :indeterminate="groupIndeterminate(group)"
+                @change="checked => toggleGroup(group, checked)"
+              >
+                {{ group.name }}
+              </el-checkbox>
+              <span>{{ groupCount(group) }}/{{ group.items.length }}</span>
+            </div>
+            <el-checkbox-group v-model="current.perms">
+              <el-checkbox
+                v-for="item in group.items"
+                :key="item.key"
+                :value="item.key"
+                :label="item.key"
+              >{{ item.label }}</el-checkbox>
+            </el-checkbox-group>
+          </article>
+        </div>
+      </section>
+
+      <el-empty v-else class="detail" description="请选择左侧成员" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import { factoryMemberRemove, factoryMembers, factoryMemberSave } from "@/api/ps";
+import { useUserStore } from "@/store/modules/user";
+
+defineOptions({
+  name: "FactoryMembers"
+});
+
+const store = useUserStore();
+const factory = computed(() => store.factory || {});
+const loading = ref(false);
+const members = ref([]);
+const catalog = ref([]);
+const currentId = ref(0);
+
+const permGroups = computed(() => {
+  const map = {};
+  catalog.value.forEach(item => {
+    if (!map[item.group]) {
+      map[item.group] = [];
+    }
+    map[item.group].push(item);
+  });
+  return Object.keys(map).map(name => ({ name, items: map[name] }));
+});
+
+const current = computed(() => members.value.find(row => row.id === currentId.value) || null);
+
+function initial(row) {
+  const name = String(row.nickname || row.username || "?");
+  return name.slice(0, 1).toUpperCase();
+}
+
+function permSummary(row) {
+  const total = catalog.value.length;
+  const count = (row.perms || []).length;
+  if (!total) {
+    return "操作员";
+  }
+  if (count >= total) {
+    return "已开通全部功能";
+  }
+  if (count === 0) {
+    return "未开通功能";
+  }
+  return `已开通 ${count}/${total} 项`;
+}
+
+function samePerms(a, b) {
+  const left = [...(a || [])].sort().join(",");
+  const right = [...(b || [])].sort().join(",");
+  return left === right;
+}
+
+function isDirty(row) {
+  return !row.is_owner && !samePerms(row.perms, row.savedPerms);
+}
+
+function groupCount(group) {
+  if (!current.value) {
+    return 0;
+  }
+  return group.items.filter(item => current.value.perms.includes(item.key)).length;
+}
+
+function groupChecked(group) {
+  return group.items.length > 0 && groupCount(group) === group.items.length;
+}
+
+function groupIndeterminate(group) {
+  const count = groupCount(group);
+  return count > 0 && count < group.items.length;
+}
+
+function toggleGroup(group, checked) {
+  if (!current.value || current.value.is_owner) {
+    return;
+  }
+  const on = checked === true;
+  const keys = group.items.map(item => item.key);
+  if (on) {
+    current.value.perms = [...new Set([...current.value.perms, ...keys])];
+  } else {
+    current.value.perms = current.value.perms.filter(key => !keys.includes(key));
+  }
+}
+
+function setAll(on) {
+  if (!current.value || current.value.is_owner) {
+    return;
+  }
+  current.value.perms = on ? catalog.value.map(item => item.key) : [];
+}
+
+function formatTime(ts) {
+  if (!ts) {
+    return "";
+  }
+  const d = new Date(Number(ts) * 1000);
+  return d.toLocaleString();
+}
+
+async function copyInvite() {
+  const code = factory.value.invite_code;
+  if (!code) {
+    return;
+  }
+  try {
+    await navigator.clipboard.writeText(code);
+    ElMessage.success("邀请码已复制");
+  } catch (e) {
+    ElMessage.warning("请手动复制邀请码");
+  }
+}
+
+async function selectMember(row) {
+  if (current.value && isDirty(current.value) && current.value.id !== row.id) {
+    try {
+      await ElMessageBox.confirm("当前成员的权限还未保存,切换后会丢掉这次勾选。", "未保存");
+    } catch (e) {
+      return;
+    }
+    current.value.perms = [...current.value.savedPerms];
+  }
+  currentId.value = row.id;
+}
+
+async function load() {
+  loading.value = true;
+  try {
+    await store.loadFactory();
+    const data = await factoryMembers();
+    catalog.value = data.catalog || [];
+    members.value = (data.rows || []).map(row => ({
+      ...row,
+      perms: [...(row.perms || [])],
+      savedPerms: [...(row.perms || [])],
+      saving: false,
+      removing: false
+    }));
+    const keep = members.value.find(row => row.id === currentId.value);
+    const firstOperator = members.value.find(row => !row.is_owner);
+    currentId.value = keep ? keep.id : (firstOperator || members.value[0] || {}).id || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function saveMember(row) {
+  row.saving = true;
+  try {
+    const data = await factoryMemberSave(row.id, row.perms || []);
+    row.perms = [...(data.perms || row.perms)];
+    row.savedPerms = [...row.perms];
+    ElMessage.success("已保存,对方刷新后生效");
+  } finally {
+    row.saving = false;
+  }
+}
+
+async function removeMember(row) {
+  await ElMessageBox.confirm(
+    `确定将 ${row.nickname || row.username} 移出本厂?对方将无法再进入本厂工作台。`,
+    "移出成员"
+  );
+  row.removing = true;
+  try {
+    await factoryMemberRemove(row.id);
+    ElMessage.success("已移出");
+    currentId.value = 0;
+    await load();
+  } finally {
+    row.removing = false;
+  }
+}
+
+onMounted(load);
+</script>
+
+<style scoped>
+.members-page {
+  min-height: calc(100vh - 148px);
+}
+.head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+h2 {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 720;
+  color: #0f172a;
+}
+.head p,
+.invite em,
+.detail-head p {
+  margin: 6px 0 0;
+  color: #64748b;
+  font-size: 13px;
+  font-style: normal;
+  font-weight: 400;
+}
+.invite {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 16px;
+  padding: 12px 14px;
+  border: 1px solid #eef1f6;
+  border-radius: 12px;
+  background: #f8fafc;
+  color: #334155;
+  font-size: 13px;
+}
+.layout {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+  gap: 16px;
+  min-height: 520px;
+}
+.list,
+.detail {
+  border: 1px solid #eef1f6;
+  border-radius: 16px;
+  background: #fff;
+}
+.list {
+  padding: 12px;
+  overflow: auto;
+}
+.list-title {
+  padding: 6px 8px 12px;
+  color: #64748b;
+  font-size: 12px;
+}
+.person {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  margin-bottom: 6px;
+  padding: 10px;
+  border: 1px solid transparent;
+  border-radius: 12px;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+.person:hover,
+.person.active {
+  background: #f5f7ff;
+}
+.person.active {
+  border-color: #d9e0ff;
+}
+.person.dirty:not(.active) {
+  background: #fff7ed;
+}
+.avatar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  border-radius: 10px;
+  background: #4f6ef7;
+  color: #fff;
+  font-weight: 700;
+}
+.meta {
+  min-width: 0;
+  flex: 1;
+}
+.meta b,
+.detail-head h3 {
+  display: block;
+  overflow: hidden;
+  color: #0f172a;
+  font-size: 14px;
+  font-weight: 650;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.meta i {
+  display: block;
+  margin-top: 2px;
+  color: #64748b;
+  font-size: 12px;
+  font-style: normal;
+}
+.detail {
+  padding: 18px;
+}
+.detail-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+.detail-head h3 {
+  margin: 0;
+  font-size: 18px;
+}
+.detail-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.groups {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+  gap: 12px;
+}
+.group {
+  padding: 14px 16px 8px;
+  border: 1px solid #eef1f6;
+  border-radius: 14px;
+  background: #fafbff;
+}
+.group-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  color: #64748b;
+  font-size: 12px;
+}
+.group :deep(.el-checkbox-group) {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.group :deep(.el-checkbox) {
+  margin-right: 0;
+  height: 32px;
+}
+@media (max-width: 960px) {
+  .layout {
+    grid-template-columns: 1fr;
+  }
+  .list {
+    max-height: 280px;
+  }
+}
+</style>

+ 1 - 0
vue/src/views/factory/index.vue

@@ -15,6 +15,7 @@
     </el-descriptions>
     <div v-if="!editing && store.isOwner" style="margin-top:16px">
       <el-button type="primary" @click="startEdit">修改资料</el-button>
+      <el-button @click="$router.push('/factory/members')">成员权限</el-button>
     </div>
 
     <div v-if="!editing && store.isOwner" class="api-box">

+ 87 - 82
vue/src/views/login/index.vue

@@ -3,7 +3,7 @@ import Motion from "./utils/motion";
 import { useRouter } from "vue-router";
 import { message } from "@/utils/message";
 import { loginRules } from "./utils/rule";
-import { ref, reactive, toRaw } from "vue";
+import { ref, reactive } from "vue";
 import { debounce } from "@pureadmin/utils";
 import { useNav } from "@/layout/hooks/useNav";
 import { useEventListener } from "@vueuse/core";
@@ -11,7 +11,6 @@ import type { FormInstance } from "element-plus";
 import { useLayout } from "@/layout/hooks/useLayout";
 import { useUserStoreHook } from "@/store/modules/user";
 import { initRouter, getTopMenu } from "@/router/utils";
-import { bg, avatar, illustration } from "./utils/static";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
 
@@ -53,7 +52,6 @@ const onLogin = async (formEl: FormInstance | undefined) => {
         })
         .then(res => {
           if (res.success) {
-            // 获取后端路由
             return initRouter().then(() => {
               disabled.value = true;
               router
@@ -89,10 +87,8 @@ useEventListener(document, "keydown", ({ code }) => {
 </script>
 
 <template>
-  <div class="select-none">
-    <img :src="bg" class="wave" />
-    <div class="flex-c absolute right-5 top-3">
-      <!-- 主题 -->
+  <div class="auth-page select-none">
+    <div class="auth-theme">
       <el-switch
         v-model="dataTheme"
         inline-prompt
@@ -101,86 +97,95 @@ useEventListener(document, "keydown", ({ code }) => {
         @change="dataThemeChange"
       />
     </div>
-    <div class="login-container">
-      <div class="img">
-        <component :is="toRaw(illustration)" />
-      </div>
-      <div class="login-box">
-        <div class="login-form">
-          <avatar class="avatar" />
-          <Motion>
-            <h2 class="outline-hidden">{{ title }}</h2>
+    <section class="auth-side">
+      <router-link to="/" class="auth-logo">{{ title }}</router-link>
+      <p class="auth-kicker">给车间用的智能套打</p>
+      <h1>把工单数据,准确地打在已印好的纸上</h1>
+      <ul class="auth-points">
+        <li>导入文件,AI 帮你认出编号和工序</li>
+        <li>数量只在套打时填写,一版一张</li>
+        <li>按原纸格子对齐,字打在该打的地方</li>
+      </ul>
+      <p class="auth-home">先看看介绍?<router-link to="/">返回首页</router-link></p>
+    </section>
+    <section class="auth-main">
+      <div class="auth-card">
+        <Motion>
+          <h2>登录账号</h2>
+        </Motion>
+        <Motion :delay="60">
+          <p class="auth-sub">进入本厂工作台,继续套打流程单</p>
+        </Motion>
+        <el-form
+          ref="ruleFormRef"
+          :model="ruleForm"
+          :rules="loginRules"
+          size="large"
+        >
+          <Motion :delay="100">
+            <el-form-item
+              :rules="[
+                {
+                  required: true,
+                  message: '请输入账号',
+                  trigger: 'blur'
+                }
+              ]"
+              prop="username"
+            >
+              <el-input
+                v-model="ruleForm.username"
+                clearable
+                placeholder="请输入账号"
+                :prefix-icon="useRenderIcon(User)"
+              />
+            </el-form-item>
           </Motion>
-
-          <el-form
-            ref="ruleFormRef"
-            :model="ruleForm"
-            :rules="loginRules"
-            size="large"
-          >
-            <Motion :delay="100">
-              <el-form-item
-                :rules="[
-                  {
-                    required: true,
-                    message: '请输入账号',
-                    trigger: 'blur'
-                  }
-                ]"
-                prop="username"
-              >
-                <el-input
-                  v-model="ruleForm.username"
-                  clearable
-                  placeholder="账号"
-                  :prefix-icon="useRenderIcon(User)"
-                />
-              </el-form-item>
-            </Motion>
-
-            <Motion :delay="150">
-              <el-form-item prop="password">
-                <el-input
-                  v-model="ruleForm.password"
-                  clearable
-                  show-password
-                  placeholder="密码"
-                  :prefix-icon="useRenderIcon(Lock)"
-                />
-              </el-form-item>
-            </Motion>
-
-            <Motion :delay="250">
-              <el-button
-                class="w-full mt-4!"
-                size="default"
-                type="primary"
-                :loading="loading"
-                :disabled="disabled"
-                @click="onLogin(ruleFormRef)"
-              >
-                登录
-              </el-button>
-            </Motion>
-            <p class="mt-4 text-center text-sm">
-              还没有工厂账号?
-              <router-link class="text-primary" to="/register"
-                >注册开厂或加入工厂</router-link
-              >
-            </p>
-          </el-form>
-        </div>
+          <Motion :delay="150">
+            <el-form-item prop="password">
+              <el-input
+                v-model="ruleForm.password"
+                clearable
+                show-password
+                placeholder="请输入密码"
+                :prefix-icon="useRenderIcon(Lock)"
+              />
+            </el-form-item>
+          </Motion>
+          <Motion :delay="220">
+            <el-button
+              class="auth-submit"
+              type="primary"
+              :loading="loading"
+              :disabled="disabled"
+              @click="onLogin(ruleFormRef)"
+            >
+              登录
+            </el-button>
+          </Motion>
+          <Motion :delay="280">
+            <div class="auth-extra">
+              <p class="auth-extra-title">还没有账号?选择一种方式</p>
+              <div class="auth-extra-btns">
+                <el-button class="auth-extra-btn" @click="router.push('/register')">
+                  注册工厂
+                </el-button>
+                <el-button
+                  class="auth-extra-btn"
+                  @click="router.push({ path: '/register', query: { mode: 'join' } })"
+                >
+                  加入工厂
+                </el-button>
+              </div>
+              <p class="auth-extra-hint">注册工厂会创建本厂并成为管理员;加入工厂请向管理员要邀请码。</p>
+            </div>
+          </Motion>
+        </el-form>
       </div>
-    </div>
+    </section>
   </div>
 </template>
 
 <style scoped>
 @import url("@/style/login.css");
 </style>
-
-<style lang="scss" scoped>
-:deep(.el-input-group__append, .el-input-group__prepend) {
-  padding: 0;
-}
-</style>

+ 3 - 2
vue/src/views/market/List.vue

@@ -26,14 +26,14 @@
               @click="editOwn(item)"
             >改定价</el-button>
             <el-button
-              v-if="!item.is_own"
+              v-if="!item.is_own && hasPerms('market.buy')"
               type="primary"
               size="small"
               :loading="buying === item.id + '-single'"
               @click="onBuy(item, 'single')"
             >买单次</el-button>
             <el-button
-              v-if="!item.is_own"
+              v-if="!item.is_own && hasPerms('market.buy')"
               type="success"
               size="small"
               :loading="buying === item.id + '-buyout'"
@@ -66,6 +66,7 @@ import { fileUrl } from '@/utils/file'
 import { openPrint } from '@/utils/print'
 import { useUserStore } from '@/store/modules/user'
 import PublishMarketDialog from '@/components/PublishMarketDialog.vue'
+import { hasPerms } from '@/utils/auth'
 
 const store = useUserStore()
 const keyword = ref('')

+ 12 - 4
vue/src/views/print/History.vue

@@ -5,13 +5,19 @@
     </div>
     <el-table :data="rows" v-loading="loading">
       <el-table-column prop="print_no" label="流水号" width="180" />
-      <el-table-column prop="biz_type" label="类型" width="100" />
+      <el-table-column label="类型" width="100">
+        <template #default="{ row }">{{ printFieldText("biz_type", row.biz_type) }}</template>
+      </el-table-column>
       <el-table-column prop="copies" label="份数" width="80" />
-      <el-table-column prop="lodop_mode" label="模式" width="100" />
-      <el-table-column prop="status" label="状态" width="100" />
+      <el-table-column label="模式" width="100">
+        <template #default="{ row }">{{ printFieldText("lodop_mode", row.lodop_mode) }}</template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template #default="{ row }">{{ printFieldText("status", row.status) }}</template>
+      </el-table-column>
       <el-table-column label="操作" width="90">
         <template #default="{ row }">
-          <el-button text type="danger" @click="onDel(row)">删除</el-button>
+          <el-button v-if="hasPerms('print.delete')" text type="danger" @click="onDel(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -32,6 +38,8 @@ import { onMounted, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { printDel, printHistory } from '@/api/ps'
 import InstallLodopButton from '@/components/InstallLodopButton.vue'
+import { printFieldText } from '@/utils/print'
+import { hasPerms } from '@/utils/auth'
 
 const rows = ref([])
 const total = ref(0)

+ 3 - 2
vue/src/views/product/Edit.vue

@@ -11,8 +11,8 @@
       </el-form-item>
       <el-form-item>
         <el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
-        <el-button v-if="form.id" type="success" @click="openPrint({ bizType: 'product', bizId: form.id, no: form.code, name: form.name })">套打</el-button>
-        <el-button v-if="form.id" type="danger" plain @click="onDel">删除</el-button>
+        <el-button v-if="form.id && hasPerms('print.do')" type="success" @click="openPrint({ bizType: 'product', bizId: form.id, no: form.code, name: form.name })">套打</el-button>
+        <el-button v-if="form.id && hasPerms('product.delete')" type="danger" plain @click="onDel">删除</el-button>
         <el-button @click="$router.push('/products')">返回</el-button>
       </el-form-item>
     </el-form>
@@ -26,6 +26,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import ProcessEditor from '@/components/ProcessEditor.vue'
 import { productDel, productInfo, productSave } from '@/api/ps'
 import { openPrint } from '@/utils/print'
+import { hasPerms } from '@/utils/auth'
 
 const route = useRoute()
 const router = useRouter()

+ 6 - 5
vue/src/views/product/List.vue

@@ -3,8 +3,8 @@
     <div class="toolbar">
       <el-input v-model="keyword" placeholder="搜索编码 / 名称" style="width:240px" clearable @keyup.enter="load" />
       <div>
-        <el-button @click="importVisible = true">文件导入</el-button>
-        <el-button type="primary" @click="$router.push('/products/edit')">手工新建</el-button>
+        <el-button v-if="hasPerms('product.import')" @click="importVisible = true">文件导入</el-button>
+        <el-button v-if="hasPerms('product.edit')" type="primary" @click="$router.push('/products/edit')">手工新建</el-button>
       </div>
     </div>
     <el-table :data="rows" v-loading="loading">
@@ -14,9 +14,9 @@
       <el-table-column prop="unit" label="单位" width="80" />
       <el-table-column label="操作" width="260">
         <template #default="{ row }">
-          <el-button text type="success" @click="openPrint({ bizType: 'product', bizId: row.id, no: row.code, name: row.name })">套打</el-button>
-          <el-button text type="primary" @click="$router.push('/products/edit/' + row.id)">编辑</el-button>
-          <el-button text type="danger" @click="onDel(row)">删除</el-button>
+          <el-button v-if="hasPerms('print.do')" text type="success" @click="openPrint({ bizType: 'product', bizId: row.id, no: row.code, name: row.name })">套打</el-button>
+          <el-button v-if="hasPerms('product.edit')" text type="primary" @click="$router.push('/products/edit/' + row.id)">编辑</el-button>
+          <el-button v-if="hasPerms('product.delete')" text type="danger" @click="onDel(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -39,6 +39,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import { productDel, productList } from '@/api/ps'
 import ImportDialog from '@/components/ImportDialog.vue'
 import { openPrint } from '@/utils/print'
+import { hasPerms } from '@/utils/auth'
 const keyword = ref('')
 const rows = ref([])
 const total = ref(0)

+ 85 - 66
vue/src/views/register/index.vue

@@ -1,92 +1,111 @@
 <template>
-  <div class="auth">
-    <div class="panel">
-      <h1>注册工厂账号</h1>
-      <p>不填邀请码则创建本厂并成为管理员;填写邀请码则加入该厂做操作员。</p>
-      <el-form :model="form" label-position="top">
-        <el-form-item label="用户名" required>
-          <el-input v-model="form.username" />
-        </el-form-item>
-        <el-form-item label="密码" required>
-          <el-input v-model="form.password" type="password" show-password />
-        </el-form-item>
-        <el-form-item label="昵称">
-          <el-input v-model="form.nickname" placeholder="可留空,默认用用户名" />
-        </el-form-item>
-        <el-form-item label="手机号">
-          <el-input v-model="form.mobile" />
-        </el-form-item>
-        <el-form-item label="工厂邀请码">
-          <el-input v-model="form.invite_code" placeholder="有邀请码则加入已有工厂" />
-        </el-form-item>
-        <el-button type="primary" size="large" style="width:100%" :loading="loading" @click="onSubmit">注册</el-button>
-      </el-form>
-      <div class="link">已有账号?<router-link to="/login">去登录</router-link></div>
-    </div>
+  <div class="auth-page">
+    <section class="auth-side">
+      <router-link to="/" class="auth-logo">{{ brandName }}</router-link>
+      <p class="auth-kicker">给车间用的智能套打</p>
+      <h1>把工单数据,准确地打在已印好的纸上</h1>
+      <ul class="auth-points">
+        <li>导入文件,AI 帮你认出编号和工序</li>
+        <li>数量只在套打时填写,一版一张</li>
+        <li>按原纸格子对齐,字打在该打的地方</li>
+      </ul>
+      <p class="auth-home">先看看介绍?<router-link to="/">返回首页</router-link></p>
+    </section>
+    <section class="auth-main">
+      <div class="auth-card auth-card-wide">
+        <div class="auth-tabs">
+          <button type="button" class="auth-tab" :class="{ on: !isJoin }" @click="setMode('create')">注册工厂</button>
+          <button type="button" class="auth-tab" :class="{ on: isJoin }" @click="setMode('join')">加入工厂</button>
+        </div>
+        <h2>{{ isJoin ? "加入已有工厂" : "注册并创建工厂" }}</h2>
+        <p class="auth-sub">
+          {{ isJoin ? "向本厂管理员索取邀请码,注册后成为操作员。" : "不填邀请码,将创建本厂并成为管理员。" }}
+        </p>
+        <el-form :model="form" label-position="top" size="large" @submit.prevent="onSubmit">
+          <el-form-item label="用户名" required>
+            <el-input v-model="form.username" placeholder="用于登录的账号" />
+          </el-form-item>
+          <el-form-item label="密码" required>
+            <el-input v-model="form.password" type="password" show-password placeholder="请设置登录密码" />
+          </el-form-item>
+          <el-form-item label="昵称">
+            <el-input v-model="form.nickname" placeholder="可留空,默认用用户名" />
+          </el-form-item>
+          <el-form-item label="手机号">
+            <el-input v-model="form.mobile" placeholder="方便本厂联系,可留空" />
+          </el-form-item>
+          <el-form-item v-if="isJoin" label="工厂邀请码" required>
+            <el-input v-model="form.invite_code" placeholder="请输入管理员提供的邀请码" />
+          </el-form-item>
+          <el-button class="auth-submit" type="primary" :loading="loading" @click="onSubmit">
+            {{ isJoin ? "加入工厂" : "注册工厂" }}
+          </el-button>
+        </el-form>
+        <p class="auth-link">已有账号?<router-link to="/login">去登录</router-link></p>
+      </div>
+    </section>
   </div>
 </template>
 
 <script setup>
-import { reactive, ref } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import { useUserStore } from '@/store/modules/user'
-import { initRouter, getTopMenu } from '@/router/utils'
+import { computed, reactive, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { ElMessage } from "element-plus";
+import { useUserStore } from "@/store/modules/user";
+import { useSiteStore } from "@/store/modules/site";
+import { initRouter, getTopMenu } from "@/router/utils";
 
-const router = useRouter()
-const store = useUserStore()
-const loading = ref(false)
+const router = useRouter();
+const route = useRoute();
+const store = useUserStore();
+const site = useSiteStore();
+const brandName = computed(() => site.title);
+const loading = ref(false);
 const form = reactive({
-  username: '',
-  password: '',
-  nickname: '',
-  mobile: '',
-  invite_code: '',
-})
+  username: "",
+  password: "",
+  nickname: "",
+  mobile: "",
+  invite_code: ""
+});
+const isJoin = computed(() => String(route.query.mode || "") === "join");
+
+function setMode(mode) {
+  router.replace({ path: "/register", query: mode === "join" ? { mode: "join" } : {} });
+}
 
 async function onSubmit() {
   if (!form.username || !form.password) {
-    ElMessage.warning('请填写用户名和密码')
-    return
+    ElMessage.warning("请填写用户名和密码");
+    return;
+  }
+  if (isJoin.value && !String(form.invite_code || "").trim()) {
+    ElMessage.warning("请填写工厂邀请码");
+    return;
   }
-  loading.value = true
+  loading.value = true;
   try {
     const res = await store.register({
       username: form.username,
       password: form.password,
       nickname: form.nickname,
-      email: '',
+      email: "",
       mobile: form.mobile,
-      invite_code: form.invite_code,
-    })
+      invite_code: isJoin.value ? String(form.invite_code || "").trim() : ""
+    });
     if (!res?.success) {
-      ElMessage.error('注册失败')
-      return
+      ElMessage.error("注册失败");
+      return;
     }
-    await initRouter()
-    ElMessage.success('注册成功')
-    router.replace(getTopMenu(true).path)
+    await initRouter();
+    ElMessage.success("注册成功");
+    router.replace(getTopMenu(true).path);
   } finally {
-    loading.value = false
+    loading.value = false;
   }
 }
 </script>
 
 <style scoped>
-.auth {
-  min-height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: linear-gradient(160deg, #1d2b3a 0%, #3d6b8c 100%);
-}
-.panel {
-  width: 460px;
-  background: #fff;
-  border-radius: 12px;
-  padding: 36px 32px;
-}
-h1 { margin: 0 0 8px; }
-p { color: #7a8794; margin: 0 0 20px; line-height: 1.6; }
-.link { margin-top: 16px; text-align: center; }
+@import url("@/style/login.css");
 </style>

+ 143 - 39
vue/src/views/template/List.vue

@@ -3,40 +3,64 @@
     <div class="toolbar">
       <span>模板由管理后台上传识版。本页列出官方模板和本厂模板,点套打即可按标准底图填入工单/产品数据。</span>
     </div>
-    <el-row :gutter="16" v-loading="loading">
-      <el-col :xs="24" :sm="12" :md="8" v-for="row in rows" :key="row.id">
-        <el-card class="tpl-card" shadow="hover">
-          <div class="cover" :style="coverBox(row)">
-            <img v-if="coverOf(row)" class="cover-img" :src="coverOf(row)" :alt="row.name" />
-            <div v-else class="cover-empty">暂无底图</div>
-            <div v-if="coverOf(row)" class="cells">
-              <div
-                v-for="cell in cellsOf(row)"
-                :key="cell.id"
-                class="cell"
-                :style="cellStyle(cell)"
-                :title="cellLabel(cell)"
-              />
-            </div>
+    <div class="tpl-grid" v-loading="loading">
+      <el-card class="tpl-card" shadow="hover" v-for="row in rows" :key="row.id">
+        <div class="cover" :style="coverBox(row)" :class="{ clickable: !!coverOf(row) }" @click="openPreview(row)">
+          <img v-if="coverOf(row)" class="cover-img" :src="coverOf(row)" :alt="row.name" />
+          <div v-else class="cover-empty">暂无底图</div>
+          <div v-if="coverOf(row)" class="cells">
+            <div
+              v-for="cell in cellsOf(row)"
+              :key="cell.id"
+              class="cell"
+              :style="cellStyle(cell)"
+              :title="cellLabel(cell)"
+            />
           </div>
-          <h3>{{ row.name }}</h3>
-          <p class="meta">{{ sourceText(row) }} · {{ paperText(row) }} · {{ cellsOf(row).length }} 个填写格</p>
-          <p v-if="row.market && row.market.on_sale" class="listed">
-            商城在售 · 单次 {{ row.market.price_single }} / 买断 {{ row.market.price_buyout }}
-          </p>
-          <el-button type="success" @click="openPrint({ templateId: row.id })">套打</el-button>
-          <el-button
-            v-if="store.isOwner && row.can_publish"
-            type="warning"
-            plain
-            @click="openPublish(row)"
-          >{{ row.market && row.market.on_sale ? '改商城价' : '上架商城' }}</el-button>
-          <el-button v-if="row.can_delete !== false && Number(row.is_system) !== 1" type="danger" plain @click="onDel(row)">删除</el-button>
-        </el-card>
-      </el-col>
-    </el-row>
+          <span v-if="coverOf(row)" class="zoom-tip">点击放大</span>
+        </div>
+        <h3 :title="row.name">{{ row.name }}</h3>
+        <p class="meta">{{ sourceText(row) }} · {{ paperText(row) }} · {{ cellsOf(row).length }} 格</p>
+        <p v-if="row.market && row.market.on_sale" class="listed">
+          商城在售 · 单次 {{ row.market.price_single }} / 买断 {{ row.market.price_buyout }}
+        </p>
+        <el-button v-if="hasPerms('print.do')" size="small" type="success" @click="openPrint({ templateId: row.id })">套打</el-button>
+        <el-button
+          v-if="store.isOwner && row.can_publish"
+          size="small"
+          type="warning"
+          plain
+          @click="openPublish(row)"
+        >{{ row.market && row.market.on_sale ? '改商城价' : '上架商城' }}</el-button>
+        <el-button v-if="hasPerms('template.delete') && row.can_delete !== false && Number(row.is_system) !== 1" size="small" type="danger" plain @click="onDel(row)">删除</el-button>
+      </el-card>
+    </div>
     <el-empty v-if="!loading && !rows.length" description="还没有可用模板。请联系管理员在后台上传标准图和色块图。" />
     <PublishMarketDialog v-model="publishVisible" :template="publishRow" @done="load" />
+    <el-dialog
+      v-model="previewVisible"
+      :title="previewRow ? previewRow.name : '模板预览'"
+      width="auto"
+      top="4vh"
+      append-to-body
+      class="tpl-preview-dialog"
+      destroy-on-close
+    >
+      <div v-if="previewRow" class="preview-stage">
+        <div class="preview-cover" :style="previewBox(previewRow)">
+          <img class="cover-img" :src="coverOf(previewRow)" :alt="previewRow.name" />
+          <div class="cells">
+            <div
+              v-for="cell in cellsOf(previewRow)"
+              :key="cell.id"
+              class="cell"
+              :style="cellStyle(cell)"
+              :title="cellLabel(cell)"
+            />
+          </div>
+        </div>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
@@ -48,24 +72,40 @@ import { fileUrl } from '@/utils/file'
 import { openPrint } from '@/utils/print'
 import { useUserStore } from '@/store/modules/user'
 import PublishMarketDialog from '@/components/PublishMarketDialog.vue'
+import { hasPerms } from '@/utils/auth'
 const store = useUserStore()
 const rows = ref([])
 const loading = ref(false)
 const publishVisible = ref(false)
 const publishRow = ref(null)
+const previewVisible = ref(false)
+const previewRow = ref(null)
 
 function openPublish(row) {
   publishRow.value = row
   publishVisible.value = true
 }
 
-function coverBox(row) {
-  const page = (row.schema && row.schema.page) || {}
+function pageSize(row) {
+  const page = (row && row.schema && row.schema.page) || {}
   const w = Number(page.w) || Number(row.page_w) || 210
   const h = Number(page.h) || Number(row.page_h) || 297
+  return { w, h }
+}
+
+function coverBox(row) {
+  const { w, h } = pageSize(row)
   return { paddingTop: ((h / w) * 100) + '%' }
 }
 
+function previewBox(row) {
+  const { w, h } = pageSize(row)
+  return {
+    '--pw': String(w),
+    '--ph': String(h),
+  }
+}
+
 function coverOf(row) {
   const schema = (row && row.schema) || {}
   return fileUrl(
@@ -73,6 +113,14 @@ function coverOf(row) {
   )
 }
 
+function openPreview(row) {
+  if (!coverOf(row)) {
+    return
+  }
+  previewRow.value = row
+  previewVisible.value = true
+}
+
 function cellsOf(row) {
   const schema = row.schema && typeof row.schema === 'object' ? row.schema : {}
   const cells = Array.isArray(schema.cells) ? schema.cells : []
@@ -144,14 +192,20 @@ onMounted(load)
 </script>
 
 <style scoped>
-.tpl-card { margin-bottom: 16px; }
+.tpl-grid {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 12px;
+}
+.tpl-card :deep(.el-card__body) { padding: 10px; }
 .cover {
   position: relative;
-  margin-bottom: 12px;
-  border-radius: 8px;
+  margin-bottom: 8px;
+  border-radius: 6px;
   overflow: hidden;
   background: #f3f5f8;
 }
+.cover.clickable { cursor: zoom-in; }
 .cover-img,
 .cover-empty,
 .cells {
@@ -166,6 +220,20 @@ onMounted(load)
   display: flex;
   align-items: center;
   justify-content: center;
+  font-size: 12px;
+}
+.zoom-tip {
+  position: absolute;
+  right: 6px;
+  bottom: 6px;
+  z-index: 2;
+  padding: 1px 6px;
+  border-radius: 4px;
+  background: rgba(0, 0, 0, 0.45);
+  color: #fff;
+  font-size: 11px;
+  line-height: 1.6;
+  pointer-events: none;
 }
 .cells { pointer-events: none; }
 .cell {
@@ -174,7 +242,43 @@ onMounted(load)
   border: 1px solid rgba(64, 158, 255, 0.95);
   background: rgba(64, 158, 255, 0.18);
 }
-h3 { margin: 0 0 6px; font-size: 16px; }
-.meta { color: #7a8794; margin: 0 0 8px; font-size: 13px; }
-.listed { color: #e6a23c; margin: 0 0 12px; font-size: 13px; }
+h3 {
+  margin: 0 0 4px;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.meta { color: #7a8794; margin: 0 0 6px; font-size: 12px; }
+.listed { color: #e6a23c; margin: 0 0 8px; font-size: 12px; }
+.preview-stage {
+  display: flex;
+  justify-content: center;
+}
+.preview-cover {
+  position: relative;
+  width: min(86vw, calc(72vh * var(--pw, 148) / var(--ph, 210)));
+  aspect-ratio: var(--pw, 148) / var(--ph, 210);
+  max-height: 72vh;
+  background: #fff;
+  overflow: hidden;
+}
+@media (max-width: 1400px) {
+  .tpl-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+@media (max-width: 1100px) {
+  .tpl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 768px) {
+  .tpl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+</style>
+
+<style>
+.tpl-preview-dialog.el-dialog {
+  margin: 4vh auto;
+}
+.tpl-preview-dialog .el-dialog__body {
+  padding: 8px 16px 16px;
+}
 </style>

+ 315 - 0
vue/src/views/welcome/dashboard.vue

@@ -0,0 +1,315 @@
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from "vue";
+import { useRouter } from "vue-router";
+import { useUserStore } from "@/store/modules/user";
+import { factoryBoard, printHistory, workorderList } from "@/api/ps";
+import InstallLodopButton from "@/components/InstallLodopButton.vue";
+import { openPrint, printFieldText } from "@/utils/print";
+import { hasPerms } from "@/utils/auth";
+
+defineOptions({
+  name: "FactoryDashboard"
+});
+
+const router = useRouter();
+const store = useUserStore();
+const loading = ref(false);
+const factory = computed(() => store.factory || {});
+const mine = reactive({
+  score: 0,
+  product: 0,
+  workorder: 0,
+  print: 0,
+  print_today: 0,
+  template: 0
+});
+const workorders = ref<any[]>([]);
+const prints = ref<any[]>([]);
+
+const metrics = computed(() =>
+  [
+    hasPerms("workorder.view")
+      ? { label: "本厂工单", value: mine.workorder, path: "/workorders" }
+      : null,
+    hasPerms("product.view")
+      ? { label: "产品工艺", value: mine.product, path: "/products" }
+      : null,
+    hasPerms("template.view")
+      ? { label: "本厂模板", value: mine.template, path: "/templates" }
+      : null,
+    hasPerms("print.view")
+      ? { label: "今日套打", value: mine.print_today, path: "/print/history" }
+      : null,
+    hasPerms("print.view")
+      ? { label: "累计套打", value: mine.print, path: "/print/history" }
+      : null,
+    { label: "工厂积分", value: mine.score, path: "/factory" }
+  ].filter(Boolean)
+);
+
+onMounted(async () => {
+  loading.value = true;
+  try {
+    await store.loadFactory();
+    const board = await factoryBoard();
+    Object.assign(mine, board.mine || {});
+    const extras = [];
+    if (hasPerms("workorder.view")) {
+      extras.push(
+        workorderList({ page: 1, limit: 8 }).then(wo => {
+          workorders.value = wo.rows || [];
+        })
+      );
+    }
+    if (hasPerms("print.view")) {
+      extras.push(
+        printHistory({ page: 1, limit: 8 }).then(jobs => {
+          prints.value = jobs.rows || [];
+        })
+      );
+    }
+    await Promise.all(extras);
+  } catch (e) {
+    /* 具体错误已由请求拦截器提示 */
+  } finally {
+    loading.value = false;
+  }
+});
+
+function formatNum(n: number) {
+  const v = Number(n) || 0;
+  if (v >= 10000) {
+    return (v / 10000).toFixed(v % 10000 === 0 ? 0 : 1) + "万";
+  }
+  return String(v);
+}
+
+function formatTime(value: number | string) {
+  const ts = Number(value);
+  if (!ts) {
+    return value || "-";
+  }
+  const d = ts > 1e12 ? new Date(ts) : new Date(ts * 1000);
+  const p = (n: number) => String(n).padStart(2, "0");
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+</script>
+
+<template>
+  <div class="board" v-loading="loading">
+    <div class="board-head">
+      <div>
+        <p class="kicker">本厂工作台</p>
+        <h1>{{ factory.name || "本厂看板" }}</h1>
+        <p class="sub">
+          {{ store.isOwner ? "管理员" : "操作员" }}
+          <span v-if="factory.code"> · {{ factory.code }}</span>
+        </p>
+      </div>
+      <div class="board-actions">
+        <InstallLodopButton />
+        <el-button v-if="hasPerms('workorder.view')" @click="router.push('/workorders')">进入工单</el-button>
+        <el-button v-if="hasPerms('workorder.edit')" type="primary" @click="router.push('/workorders/edit')">新建套打</el-button>
+      </div>
+    </div>
+
+    <div class="metrics">
+      <button
+        v-for="item in metrics"
+        :key="item.label"
+        type="button"
+        class="metric"
+        @click="router.push(item.path)"
+      >
+        <b>{{ formatNum(item.value) }}</b>
+        <span>{{ item.label }}</span>
+      </button>
+    </div>
+
+    <div class="shortcuts">
+      <article v-if="hasPerms('workorder.view')" class="shortcut" @click="router.push('/workorders')">
+        <h3>工单套打</h3>
+        <p>选择工单,按总数量和每版数量拆张后套打。</p>
+      </article>
+      <article v-if="hasPerms('product.view')" class="shortcut" @click="router.push('/products')">
+        <h3>产品工艺</h3>
+        <p>维护本厂产品编号、名称和工序。</p>
+      </article>
+      <article v-if="hasPerms('template.view')" class="shortcut" @click="router.push('/templates')">
+        <h3>本厂模板</h3>
+        <p>选用已识版样式,按原纸格子对齐打印。</p>
+      </article>
+      <article v-if="hasPerms('print.view')" class="shortcut" @click="router.push('/print/history')">
+        <h3>打印记录</h3>
+        <p>查看流水号、份数,便于对账和补打。</p>
+      </article>
+    </div>
+
+    <div class="panels">
+      <section v-if="hasPerms('workorder.view')" class="panel">
+        <div class="panel-head">
+          <h2>最近工单</h2>
+          <el-button text type="primary" @click="router.push('/workorders')">全部工单</el-button>
+        </div>
+        <el-table :data="workorders" empty-text="暂无工单">
+          <el-table-column prop="no" label="工单号" min-width="140" />
+          <el-table-column prop="product_name" label="名称" min-width="120" />
+          <el-table-column prop="status" label="状态" width="90" />
+          <el-table-column label="操作" width="150" fixed="right">
+            <template #default="{ row }">
+              <el-button
+                v-if="hasPerms('print.do')"
+                text
+                type="success"
+                @click="openPrint({ bizType: 'workorder', bizId: row.id, no: row.no, name: row.product_name })"
+              >
+                套打
+              </el-button>
+              <el-button
+                v-if="hasPerms('workorder.edit')"
+                text
+                type="primary"
+                @click="router.push('/workorders/edit/' + row.id)"
+              >编辑</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </section>
+      <section v-if="hasPerms('print.view')" class="panel">
+        <div class="panel-head">
+          <h2>最近套打</h2>
+          <el-button text type="primary" @click="router.push('/print/history')">全部记录</el-button>
+        </div>
+        <el-table :data="prints" empty-text="暂无打印记录">
+          <el-table-column prop="print_no" label="流水号" min-width="160" />
+          <el-table-column label="类型" width="80">
+            <template #default="{ row }">{{ printFieldText("biz_type", row.biz_type) }}</template>
+          </el-table-column>
+          <el-table-column prop="copies" label="份数" width="70" />
+          <el-table-column label="时间" min-width="150">
+            <template #default="{ row }">{{ formatTime(row.createtime) }}</template>
+          </el-table-column>
+        </el-table>
+      </section>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.board {
+  min-height: 100%;
+}
+.board-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  gap: 16px;
+  margin-bottom: 24px;
+}
+.kicker {
+  margin: 0 0 8px;
+  color: #4f6ef7;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+}
+h1 {
+  margin: 0;
+  font-size: 28px;
+  font-weight: 720;
+  letter-spacing: -0.03em;
+  color: #0f172a;
+}
+.sub {
+  margin: 8px 0 0;
+  color: #64748b;
+  font-size: 14px;
+}
+.board-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.metrics {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+.metric {
+  padding: 18px 16px 16px;
+  border: 1px solid #eef1f6;
+  border-radius: 16px;
+  background: #fff;
+  text-align: left;
+  cursor: pointer;
+}
+.metric b {
+  display: block;
+  color: #0f172a;
+  font-size: 26px;
+  font-weight: 720;
+  letter-spacing: -0.04em;
+}
+.metric span {
+  color: #64748b;
+  font-size: 13px;
+}
+.shortcuts {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+.shortcut {
+  padding: 20px 18px;
+  border: 1px solid #eef1f6;
+  border-radius: 16px;
+  background: #fff;
+  cursor: pointer;
+}
+.shortcut h3 {
+  margin: 0 0 8px;
+  font-size: 16px;
+  font-weight: 650;
+  color: #0f172a;
+}
+.shortcut p {
+  margin: 0;
+  color: #64748b;
+  font-size: 13px;
+  line-height: 1.7;
+}
+.panels {
+  display: grid;
+  grid-template-columns: 1.15fr 0.85fr;
+  gap: 12px;
+}
+.panel {
+  padding: 8px 8px 16px;
+  border: 1px solid #eef1f6;
+  border-radius: 16px;
+  background: #fff;
+}
+.panel-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 8px 4px;
+}
+.panel h2 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 650;
+  color: #0f172a;
+}
+@media (max-width: 1100px) {
+  .panels {
+    grid-template-columns: 1fr;
+  }
+  .board-head {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+}
+</style>

+ 724 - 61
vue/src/views/welcome/index.vue

@@ -1,98 +1,761 @@
 <script setup lang="ts">
-import { onMounted, reactive, computed } from "vue";
+import { computed, onMounted, reactive, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { useUserStore } from "@/store/modules/user";
-import { printHistory, productList, workorderList } from "@/api/ps";
+import { factoryBoard, homeBlocks } from "@/api/ps";
+import { getToken } from "@/utils/auth";
 import InstallLodopButton from "@/components/InstallLodopButton.vue";
+import { useSiteStore } from "@/store/modules/site";
 
 defineOptions({
   name: "Welcome"
 });
 
+type HomeBlock = {
+  id?: number;
+  type: string;
+  name?: string;
+  kicker?: string;
+  title?: string;
+  subtitle?: string;
+  content?: Record<string, any>;
+};
+
+const router = useRouter();
+const route = useRoute();
 const store = useUserStore();
+const isPublic = computed(() => route.path === "/");
+const loggedIn = computed(() => !!getToken()?.accessToken);
 const factory = computed(() => store.factory || {});
-const counts = reactive({ product: 0, workorder: 0, print: 0 });
+const platform = reactive({
+  factory_count: 0,
+  print_today: 0,
+  print_total: 0,
+  template_count: 0
+});
+const mine = reactive({
+  score: 0,
+  product: 0,
+  workorder: 0,
+  print: 0
+});
+const site = useSiteStore();
+const blocks = ref<HomeBlock[]>([]);
+const brandName = computed(() => site.title);
+const firstStepsIndex = computed(() =>
+  blocks.value.findIndex(item => item.type === "steps")
+);
+const firstFeaturesIndex = computed(() =>
+  blocks.value.findIndex(item => item.type === "features")
+);
+const heroMetrics = computed(() => {
+  if (loggedIn.value) {
+    return [
+      { label: "本厂工单", value: formatNum(mine.workorder), tag: "本厂" },
+      { label: "产品工艺", value: formatNum(mine.product), tag: "档案" },
+      { label: "累计套打", value: formatNum(mine.print), tag: "记录" },
+      { label: "工厂积分", value: formatNum(mine.score), tag: "积分" }
+    ];
+  }
+  return [
+    { label: "入驻厂家", value: formatNum(platform.factory_count), tag: "实时" },
+    { label: "今日打印", value: formatNum(platform.print_today), tag: "今日" },
+    { label: "累计套打", value: formatNum(platform.print_total), tag: "累计" },
+    { label: "可用模板", value: formatNum(platform.template_count), tag: "模板" }
+  ];
+});
 
 onMounted(async () => {
   try {
-    await store.loadFactory();
-    const [p, w, j] = await Promise.all([
-      productList({ limit: 1 }),
-      workorderList({ limit: 1 }),
-      printHistory({ limit: 1 })
-    ]);
-    counts.product = p.total || 0;
-    counts.workorder = w.total || 0;
-    counts.print = j.total || 0;
+    if (loggedIn.value) {
+      await store.loadFactory();
+    }
+    const [board, list] = await Promise.all([factoryBoard(), homeBlocks()]);
+    Object.assign(platform, board.platform || {});
+    Object.assign(mine, board.mine || {});
+    blocks.value = Array.isArray(list) ? list : [];
+    site.applyFromBlocks(blocks.value);
   } catch (e) {
     /* 具体错误已由请求拦截器提示 */
   }
 });
+
+function formatNum(n: number) {
+  const v = Number(n) || 0;
+  if (v >= 10000) {
+    return (v / 10000).toFixed(v % 10000 === 0 ? 0 : 1) + "万";
+  }
+  return String(v);
+}
+
+function go(path: string) {
+  if (!loggedIn.value && path !== "/login" && path !== "/register" && !path.startsWith("/register")) {
+    router.push("/login");
+    return;
+  }
+  router.push(path);
+}
+
+function scrollTo(id: string) {
+  document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
+function goAction(path?: string) {
+  const target = String(path || "").trim();
+  if (!target) {
+    return;
+  }
+  if (target.startsWith("#")) {
+    scrollTo(target.slice(1));
+    return;
+  }
+  go(target);
+}
+
+function blockItems(block: HomeBlock) {
+  return Array.isArray(block?.content?.items) ? block.content.items : [];
+}
+
+function blockNotes(block: HomeBlock) {
+  return Array.isArray(block?.content?.notes) ? block.content.notes : [];
+}
+
+function displayStat(item: Record<string, any>) {
+  const raw = String(item?.value || "").trim();
+  if (raw) {
+    return raw;
+  }
+  const key = String(item?.key || "");
+  if (key && Object.prototype.hasOwnProperty.call(platform, key)) {
+    return formatNum((platform as Record<string, number>)[key]);
+  }
+  return "0";
+}
+
+function isAnchor(path?: string) {
+  return String(path || "").trim().startsWith("#");
+}
 </script>
 
 <template>
-  <div class="cards">
-    <el-row :gutter="16">
-      <el-col :span="6">
-        <div class="stat">
-          <div class="num">{{ factory.score || 0 }}</div>
-          <div class="label">工厂积分</div>
+  <div class="home" :class="{ embedded: !isPublic, public: isPublic }">
+    <header v-if="isPublic" class="pub-nav">
+      <div class="wrap pub-nav-inner">
+        <div class="pub-brand">{{ brandName }}</div>
+        <nav class="pub-links">
+          <button
+            v-if="firstStepsIndex >= 0"
+            type="button"
+            class="pub-link"
+            @click="scrollTo('home-flow')"
+          >
+            怎么用
+          </button>
+          <button
+            v-if="firstFeaturesIndex >= 0"
+            type="button"
+            class="pub-link"
+            @click="scrollTo('home-features')"
+          >
+            能做什么
+          </button>
+        </nav>
+        <div class="pub-actions">
+          <button type="button" class="btn-text" @click="go('/login')">登录</button>
+          <button type="button" class="btn-ghost" @click="go('/register')">注册工厂</button>
+          <button type="button" class="btn-solid" @click="go('/register?mode=join')">加入工厂</button>
+        </div>
+      </div>
+    </header>
+
+    <template v-for="(block, index) in blocks" :key="block.id || index">
+      <section v-if="block.type === 'hero'" class="hero">
+        <div class="wrap hero-copy">
+          <p v-if="block.content?.eyebrow" class="eyebrow">{{ block.content.eyebrow }}</p>
+          <h1>
+            <span class="brand">{{ block.content?.brand || block.title || brandName }}</span>
+            <span v-if="block.subtitle" class="hero-title">{{ block.subtitle }}</span>
+          </h1>
+          <div v-if="blockNotes(block).length" class="pills">
+            <span v-for="note in blockNotes(block)" :key="note" class="pill">{{ note }}</span>
+          </div>
+          <p v-if="block.content?.lead" class="lead">{{ block.content.lead }}</p>
+          <div class="hero-actions">
+            <button
+              v-if="block.content?.btn_primary_text"
+              type="button"
+              class="btn-solid btn-lg"
+              @click="goAction(block.content.btn_primary_path)"
+            >
+              {{ block.content.btn_primary_text }}
+            </button>
+            <button
+              v-if="block.content?.btn_secondary_text"
+              type="button"
+              class="btn-ghost btn-lg"
+              @click="goAction(block.content.btn_secondary_path)"
+            >
+              {{ block.content.btn_secondary_text }}
+              <span v-if="isAnchor(block.content.btn_secondary_path)" class="btn-arrow">↓</span>
+            </button>
+            <InstallLodopButton v-if="!isPublic" />
+          </div>
+        </div>
+        <div class="wrap">
+          <div class="hero-stage">
+            <div class="stage-head">
+              <span class="stage-live" />
+              <span>{{ loggedIn ? factory.name || "本厂看板" : block.content?.panel_title || "今天的车间" }}</span>
+            </div>
+            <div class="stage-table">
+              <div v-for="item in heroMetrics" :key="item.label" class="stage-row">
+                <span class="stage-name">{{ item.label }}</span>
+                <span class="stage-value">{{ item.value }}</span>
+                <span class="stage-tag">{{ item.tag }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      <section v-else-if="block.type === 'stats'" class="stats-band">
+        <div class="wrap stats">
+          <div v-for="(item, itemIndex) in blockItems(block)" :key="item.label || itemIndex" class="stat">
+            <div class="stat-num">{{ displayStat(item) }}</div>
+            <div class="stat-label">{{ item.label }}</div>
+          </div>
+        </div>
+      </section>
+
+      <section
+        v-else-if="block.type === 'steps'"
+        class="block block-flow"
+        :id="index === firstStepsIndex ? 'home-flow' : `home-flow-${block.id || index}`"
+      >
+        <div class="wrap">
+          <div class="block-head">
+            <p v-if="block.kicker" class="kicker">{{ block.kicker }}</p>
+            <h2 v-if="block.title">{{ block.title }}</h2>
+            <p v-if="block.subtitle" class="block-desc">{{ block.subtitle }}</p>
+          </div>
+          <div class="steps">
+            <article v-for="(item, itemIndex) in blockItems(block)" :key="item.title || itemIndex" class="step">
+              <span class="step-no">{{ String(itemIndex + 1).padStart(2, "0") }}</span>
+              <h3>{{ item.title }}</h3>
+              <p>{{ item.desc }}</p>
+            </article>
+          </div>
         </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="stat">
-          <div class="num">{{ counts.product }}</div>
-          <div class="label">产品</div>
+      </section>
+
+      <section
+        v-else-if="block.type === 'features'"
+        class="block block-features"
+        :id="index === firstFeaturesIndex ? 'home-features' : undefined"
+      >
+        <div class="wrap">
+          <div class="block-head">
+            <p v-if="block.kicker" class="kicker">{{ block.kicker }}</p>
+            <h2 v-if="block.title">{{ block.title }}</h2>
+            <p v-if="block.subtitle" class="block-desc">{{ block.subtitle }}</p>
+          </div>
+          <div class="features">
+            <article
+              v-for="(item, itemIndex) in blockItems(block)"
+              :key="item.title || itemIndex"
+              class="feature"
+              @click="goAction(item.path)"
+            >
+              <span v-if="item.tag" class="feature-tag">{{ item.tag }}</span>
+              <h3>{{ item.title }}</h3>
+              <p>{{ item.desc }}</p>
+            </article>
+          </div>
         </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="stat">
-          <div class="num">{{ counts.workorder }}</div>
-          <div class="label">工单</div>
+      </section>
+
+      <section v-else-if="block.type === 'cta'" class="cta-band">
+        <div class="wrap cta">
+          <h2 v-if="block.title">{{ block.title }}</h2>
+          <p v-if="block.subtitle">{{ block.subtitle }}</p>
+          <div class="cta-actions">
+            <button
+              v-if="block.content?.btn_primary_text"
+              type="button"
+              class="btn-solid btn-lg"
+              @click="goAction(block.content.btn_primary_path)"
+            >
+              {{ block.content.btn_primary_text }}
+            </button>
+            <button
+              v-if="block.content?.btn_secondary_text"
+              type="button"
+              class="btn-ghost btn-lg"
+              @click="goAction(block.content.btn_secondary_path)"
+            >
+              {{ block.content.btn_secondary_text }}
+            </button>
+          </div>
         </div>
-      </el-col>
-      <el-col :span="6">
-        <div class="stat">
-          <div class="num">{{ counts.print }}</div>
-          <div class="label">打印任务</div>
+      </section>
+
+      <section v-else class="block block-custom">
+        <div class="wrap">
+          <div class="block-head">
+            <p v-if="block.kicker" class="kicker">{{ block.kicker }}</p>
+            <h2 v-if="block.title">{{ block.title }}</h2>
+            <p v-if="block.subtitle" class="block-desc">{{ block.subtitle }}</p>
+          </div>
+          <div v-if="block.content?.body" class="custom-body">{{ block.content.body }}</div>
+          <div v-if="block.content?.btn_text" class="custom-actions">
+            <button type="button" class="btn-solid" @click="goAction(block.content.btn_path)">
+              {{ block.content.btn_text }}
+            </button>
+          </div>
         </div>
-      </el-col>
-    </el-row>
-    <div class="page-card" style="margin-top: 16px">
-      <h3>快捷操作</h3>
-      <el-space wrap>
-        <el-button type="primary" @click="$router.push('/workorders/edit')"
-          >新建工单并套打</el-button
-        >
-        <el-button @click="$router.push('/products/edit')">新建产品</el-button>
-        <el-button type="success" @click="$router.push('/templates')"
-          >本厂模板</el-button
-        >
-        <InstallLodopButton />
-      </el-space>
-      <p class="tip">
-        先安装本机打印脚本(CLodop),再到工单列表点「套打」。工单信息会填进模板空白格。
-      </p>
-    </div>
+      </section>
+    </template>
   </div>
 </template>
 
 <style scoped>
+.home {
+  color: #334155;
+  background: #fff;
+}
+.home.embedded {
+  margin: -24px;
+  min-height: calc(100vh - 120px);
+}
+.home.public {
+  min-height: 100vh;
+}
+.wrap {
+  width: min(1120px, calc(100% - 80px));
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.pub-nav {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  background: rgba(255, 255, 255, 0.92);
+  backdrop-filter: blur(16px);
+  border-bottom: 1px solid rgba(15, 23, 42, 0.06);
+}
+.pub-nav-inner {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 24px;
+  min-height: 72px;
+}
+.pub-brand {
+  font-size: 18px;
+  font-weight: 700;
+  letter-spacing: 0.02em;
+  color: #0f172a;
+}
+.pub-links {
+  display: flex;
+  gap: 8px;
+  flex: 1;
+  justify-content: center;
+}
+.pub-link {
+  border: 0;
+  background: none;
+  padding: 8px 14px;
+  color: #64748b;
+  font-size: 14px;
+  cursor: pointer;
+}
+.pub-link:hover {
+  color: #0f172a;
+}
+.pub-actions {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+}
+
+.btn-solid,
+.btn-ghost,
+.btn-text {
+  border-radius: 999px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 600;
+  line-height: 1;
+}
+.btn-text {
+  border: 0;
+  background: none;
+  padding: 10px 8px;
+  color: #334155;
+}
+.btn-solid {
+  border: 0;
+  background: #4f6ef7;
+  color: #fff;
+  padding: 12px 20px;
+  box-shadow: 0 8px 20px rgba(79, 110, 247, 0.22);
+}
+.btn-solid:hover {
+  background: #3d5de8;
+}
+.btn-ghost {
+  border: 1px solid #e2e8f0;
+  background: #fff;
+  color: #0f172a;
+  padding: 11px 18px;
+}
+.btn-ghost:hover {
+  border-color: #cbd5e1;
+  background: #f8fafc;
+}
+.btn-lg {
+  padding: 16px 26px;
+  font-size: 15px;
+}
+.btn-arrow {
+  margin-left: 8px;
+  font-size: 13px;
+}
+
+.hero {
+  padding: 72px 0 24px;
+}
+.hero-copy {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  padding-bottom: 56px;
+}
+.eyebrow {
+  margin: 0 0 20px;
+  color: #4f6ef7;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.16em;
+}
+h1 {
+  margin: 0;
+  max-width: 16em;
+}
+.brand {
+  display: block;
+  color: #4f6ef7;
+  font-size: clamp(40px, 5.4vw, 64px);
+  font-weight: 760;
+  line-height: 1.12;
+  letter-spacing: -0.03em;
+}
+.hero-title {
+  display: block;
+  margin-top: 16px;
+  color: #0f172a;
+  font-size: clamp(28px, 3.6vw, 42px);
+  font-weight: 650;
+  line-height: 1.28;
+  letter-spacing: -0.03em;
+}
+.pills {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 10px;
+  margin-top: 28px;
+}
+.pill {
+  padding: 8px 14px;
+  border: 1px solid #e8ecf3;
+  border-radius: 999px;
+  background: #fff;
+  color: #475569;
+  font-size: 13px;
+}
+.lead {
+  max-width: 36em;
+  margin: 28px 0 0;
+  color: #64748b;
+  font-size: 16px;
+  line-height: 1.9;
+}
+.hero-actions {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 12px;
+  align-items: center;
+  margin-top: 36px;
+}
+
+.hero-stage {
+  overflow: hidden;
+  border: 1px solid #e8ecf3;
+  border-radius: 20px;
+  background: #fff;
+  box-shadow: 0 24px 60px rgba(15, 23, 42, 0.06);
+}
+.stage-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 22px 28px;
+  border-bottom: 1px solid #eef1f6;
+  color: #64748b;
+  font-size: 14px;
+}
+.stage-live {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #22c55e;
+  box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
+}
+.stage-table {
+  padding: 8px 12px 16px;
+}
+.stage-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto auto;
+  gap: 16px;
+  align-items: center;
+  min-height: 64px;
+  padding: 8px 16px;
+  border-radius: 12px;
+}
+.stage-row + .stage-row {
+  border-top: 1px solid #f1f5f9;
+}
+.stage-name {
+  color: #0f172a;
+  font-size: 15px;
+  font-weight: 600;
+}
+.stage-value {
+  color: #0f172a;
+  font-size: 20px;
+  font-weight: 700;
+  letter-spacing: -0.03em;
+}
+.stage-tag {
+  min-width: 52px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background: #ecfdf3;
+  color: #15803d;
+  font-size: 12px;
+  text-align: center;
+}
+
+.stats-band {
+  padding: 28px 0 12px;
+}
+.stats {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+  gap: 8px;
+  padding: 12px 0 8px;
+}
 .stat {
-  background: var(--el-bg-color);
-  border-radius: 10px;
-  padding: 20px;
-  box-shadow: 0 8px 24px rgb(29 43 58 / 6%);
+  padding: 28px 16px 20px;
+  text-align: center;
+}
+.stat-num {
+  font-size: 32px;
+  font-weight: 720;
+  letter-spacing: -0.04em;
+  color: #0f172a;
+}
+.stat-label {
+  margin-top: 8px;
+  color: #94a3b8;
+  font-size: 13px;
+}
+
+.block {
+  padding: 96px 0;
+  scroll-margin-top: 88px;
+}
+.block-flow,
+.block-custom {
+  background: #f8fafc;
+}
+.block-features {
+  background: #fff;
+}
+.block-head {
+  max-width: 640px;
+  margin-bottom: 48px;
+}
+.kicker {
+  margin: 0 0 12px;
+  color: #4f6ef7;
+  font-size: 13px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+}
+h2 {
+  margin: 0;
+  font-size: clamp(28px, 3vw, 36px);
+  font-weight: 700;
+  letter-spacing: -0.03em;
+  color: #0f172a;
+}
+.block-desc {
+  margin: 16px 0 0;
+  color: #64748b;
+  font-size: 16px;
+  line-height: 1.85;
+}
+
+.steps,
+.features {
+  display: grid;
+  gap: 20px;
+}
+.steps {
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+.features {
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+.step,
+.feature {
+  padding: 32px 28px;
+  border: 1px solid #eef1f6;
+  border-radius: 24px;
+  background: #fff;
+}
+.feature {
+  cursor: pointer;
+  transition: transform 0.18s ease, box-shadow 0.18s ease;
 }
-.num {
+.feature:hover {
+  transform: translateY(-4px);
+  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
+}
+.step-no {
+  display: block;
+  margin-bottom: 28px;
+  color: #cbd5e1;
   font-size: 28px;
   font-weight: 700;
+  letter-spacing: -0.04em;
+}
+.feature-tag {
+  display: inline-flex;
+  margin-bottom: 18px;
+  padding: 6px 12px;
+  border-radius: 999px;
+  background: #eef2ff;
+  color: #4f6ef7;
+  font-size: 12px;
+  font-weight: 700;
+}
+.step h3,
+.feature h3 {
+  margin: 0 0 12px;
+  font-size: 18px;
+  font-weight: 650;
+  color: #0f172a;
 }
-.label {
-  color: var(--el-text-color-secondary);
-  margin-top: 6px;
+.step p,
+.feature p {
+  margin: 0;
+  color: #64748b;
+  font-size: 14px;
+  line-height: 1.85;
 }
-.tip {
-  color: var(--el-text-color-secondary);
+
+.custom-body {
+  max-width: 720px;
+  color: #64748b;
+  font-size: 16px;
+  line-height: 1.9;
+  white-space: pre-wrap;
+}
+.custom-actions {
+  margin-top: 28px;
+}
+
+.cta-band {
+  padding: 96px 0 112px;
+  background: #fff;
+}
+.cta {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+  gap: 0;
+}
+.cta h2 {
+  font-size: clamp(28px, 3vw, 40px);
+}
+.cta p {
+  max-width: 28em;
   margin: 16px 0 0;
+  color: #64748b;
+  font-size: 16px;
+  line-height: 1.8;
+}
+.cta-actions {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 12px;
+  margin-top: 32px;
+}
+
+@media (max-width: 900px) {
+  .pub-links {
+    display: none;
+  }
+  .wrap {
+    width: min(1120px, calc(100% - 40px));
+  }
+  .hero {
+    padding-top: 48px;
+  }
+  .hero-copy {
+    padding-bottom: 36px;
+  }
+  .block,
+  .cta-band {
+    padding: 64px 0;
+  }
+  .pills {
+    flex-direction: column;
+    align-items: center;
+  }
+}
+
+@media (max-width: 768px) {
+  .home.embedded {
+    margin: -16px;
+  }
+  .pub-nav-inner {
+    min-height: 64px;
+  }
+  .hero-title {
+    font-size: 26px;
+  }
+  .stage-row {
+    grid-template-columns: minmax(0, 1fr) auto;
+  }
+  .stage-tag {
+    display: none;
+  }
+  .steps,
+  .features,
+  .stats {
+    grid-template-columns: 1fr;
+  }
 }
 </style>

+ 3 - 2
vue/src/views/workorder/Edit.vue

@@ -26,8 +26,8 @@
       </el-form-item>
       <el-form-item>
         <el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
-        <el-button v-if="form.id" type="success" @click="openPrint({ bizType: 'workorder', bizId: form.id, no: form.no, name: form.product_name })">套打</el-button>
-        <el-button v-if="form.id" type="danger" plain @click="onDel">删除</el-button>
+        <el-button v-if="form.id && hasPerms('print.do')" type="success" @click="openPrint({ bizType: 'workorder', bizId: form.id, no: form.no, name: form.product_name })">套打</el-button>
+        <el-button v-if="form.id && hasPerms('workorder.delete')" type="danger" plain @click="onDel">删除</el-button>
         <el-button @click="$router.push('/workorders')">返回</el-button>
       </el-form-item>
     </el-form>
@@ -41,6 +41,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 import ProcessEditor from '@/components/ProcessEditor.vue'
 import { productInfo, productList, workorderDel, workorderInfo, workorderSave } from '@/api/ps'
 import { openPrint } from '@/utils/print'
+import { hasPerms } from '@/utils/auth'
 
 const route = useRoute()
 const router = useRouter()

+ 7 - 6
vue/src/views/workorder/List.vue

@@ -4,9 +4,9 @@
       <el-input v-model="keyword" placeholder="搜索工单号 / 名称 / 批次 / 客户" style="width:280px" clearable @keyup.enter="load" />
       <div>
         <InstallLodopButton />
-        <el-button @click="historyVisible = true">打印记录</el-button>
-        <el-button @click="importVisible = true">文件导入</el-button>
-        <el-button type="primary" @click="$router.push('/workorders/edit')">手工新建</el-button>
+        <el-button v-if="hasPerms('print.view')" @click="historyVisible = true">打印记录</el-button>
+        <el-button v-if="hasPerms('workorder.import')" @click="importVisible = true">文件导入</el-button>
+        <el-button v-if="hasPerms('workorder.edit')" type="primary" @click="$router.push('/workorders/edit')">手工新建</el-button>
       </div>
     </div>
     <el-table :data="rows" v-loading="loading">
@@ -18,9 +18,9 @@
       <el-table-column prop="status" label="状态" width="100" />
       <el-table-column label="操作" width="260">
         <template #default="{ row }">
-          <el-button text type="success" @click="openPrint({ bizType: 'workorder', bizId: row.id, no: row.no, name: row.product_name })">套打</el-button>
-          <el-button text type="primary" @click="$router.push('/workorders/edit/' + row.id)">编辑</el-button>
-          <el-button text type="danger" @click="onDel(row)">删除</el-button>
+          <el-button v-if="hasPerms('print.do')" text type="success" @click="openPrint({ bizType: 'workorder', bizId: row.id, no: row.no, name: row.product_name })">套打</el-button>
+          <el-button v-if="hasPerms('workorder.edit')" text type="primary" @click="$router.push('/workorders/edit/' + row.id)">编辑</el-button>
+          <el-button v-if="hasPerms('workorder.delete')" text type="danger" @click="onDel(row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -48,6 +48,7 @@ import History from '../print/History.vue'
 import ImportDialog from '@/components/ImportDialog.vue'
 import InstallLodopButton from '@/components/InstallLodopButton.vue'
 import { openPrint } from '@/utils/print'
+import { hasPerms } from '@/utils/auth'
 const keyword = ref('')
 const rows = ref([])
 const total = ref(0)

+ 4 - 0
vue/types/router.d.ts

@@ -24,6 +24,8 @@ declare global {
     showParent?: boolean;
     /** 页面级别权限设置 `可选` */
     roles?: Array<string>;
+    /** 工厂端功能权限,命中任一即可进入 `可选` */
+    perms?: Array<string>;
     /** 按钮级别权限设置 `可选` */
     auths?: Array<string>;
     /** 路由组件缓存(开启 `true`、关闭 `false`)`可选` */
@@ -98,6 +100,8 @@ declare global {
       showLink?: boolean;
       /** 菜单升序排序,值越高排的越后(只针对顶级路由)`可选` */
       rank?: number;
+      /** 工厂端功能权限,命中任一即可显示 `可选` */
+      perms?: Array<string>;
     };
     /** 子路由配置项 */
     children?: Array<RouteChildrenConfigsTable>;