| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650 |
- <?php
- namespace zin;
- require_once dirname(__DIR__) . DS . 'btn' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'backbtn' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'content' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'history' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'tabs' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'tabpane' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'entitytitle' . DS . 'v1.php';
- require_once dirname(__DIR__) . DS . 'toolbar' . DS . 'v1.php';
- class detail extends wg
- {
- /**
- * @var mixed[]
- */
- protected static $defineProps = array
- (
- /* 布局,如果为 `simple` 则适用于对话框。 */
- 'layout' => '?string',
- /* 返回按钮,可以为:`true` 显示默认的返回按钮,`false` 不显示返回按钮,或者指定返回按钮 backBtn 的 back 属性,或者通过数组指定 backBtn 所有属性。 */
- 'backBtn' => '?bool|string|array=true',
- /* 对象类型,例如 `story`、`task` 等,如果不指定则已当前的模块名称作为对象类型。 */
- 'objectType' => '?string',
- /* 对象 ID,如果不指定则尝试使用当前页面上的 `${$objectType}->id` 或者 `${$objectType}ID` 的值,例如 `$task->id` 或 `$taskID`。 */
- 'objectID' => '?int',
- /* 对象标题颜色。 */
- 'color' => '?string',
- /* 对象,如果不指定则尝试使用当前页面上的 `${$objectType}` 的值,例如 `$task`。 */
- 'object' => '?object',
- /* 是否已删除。 */
- 'deleted?: bool',
- /* 父级对象。 */
- 'parent' => '?object',
- /* 父级对象类型。 */
- 'parentType' => '?string',
- /* 父级对象 ID。 */
- 'parentID' => '?int|string=false',
- /* 父级标题。 */
- 'parentTitle' => '?string',
- /* 父级标题链接。 */
- 'parentUrl' => '?string',
- /* 父级标题属性。 */
- 'parentTitleProps' => '?array',
- /* 标题,如果不指定则尝试使用当前页面上的 `${$objectType}->title` 或 `${$objectType}->name` 的值,例如 `$story->title`、`$task->name` 。 */
- 'title' => '?string',
- /* 标题提示,如果不指定则使用title属性。 */
- 'titleProps' => '?string',
- /* 底部固定操作按钮的定义,不包括返回按钮,可以通过 `-` 来指定分割线,如果没有指定 actions 属性,则从 actionList 和 operateList 生成。 */
- 'actions' => '?array',
- /* 对象类型可用操作配置,如果没有指定 actionList 属性,则从 $config->$objectType->actionList 获取。 */
- 'actionList' => '?array',
- /* 对象类型当前操作配置,如果没有指定 operateList 属性,则从 $config->$objectType->$methoedName->actionList 获取。 */
- 'operateList' => '?array',
- /* 操作按钮链接格式化参数。 */
- 'urlFormatter' => '?array',
- /* 右上方工具栏的定义。 */
- 'toolbar' => '?array',
- /* 详情页的左侧主栏目内容区域,可以通过 `-` 来指定分割线,通过键名指定标题,通过 `html()` 来指定 HTML 内容,或者指定为 `callable` 或 `Closure` 动态生成内容,或者指定为 `content()` 属性。 */
- 'sections' => '?array',
- /* 详情页的右侧侧边栏标签页区域,可以通过 `-` 来指定分割线,通过键名指定标题,通过 `html()` 来指定 HTML 内容,或者指定为 `callable` 或 `Closure` 动态生成内容,或者指定为 `content()` 属性。 */
- 'tabs' => '?array',
- /* 详情页的右侧侧边栏宽度,如果不指定则默认为 `370`。 */
- 'sideWidth' => '?int=370',
- /* 详情页的左侧主栏目历史记录,如果设定为 `true` 显示当前对象默认的理智记录,否如果设置为 `false` 不显示历史记录,如果设置为数组则作为 `history()` 部件的属性来创建历史记录。 */
- 'history' => '?array|bool=true',
- /* 详情链接生成模版,例如 `/m=story&f=view&storyID={id}`,如果不指定则自动根据当前模块名和方法名生成。 */
- 'linkCreator' => '?string',
- /* 上一个对象按钮链接,也可以通过数组指定按钮的所有属性,如果指定为 true,则自动从 $preAndNext 对象上获取 ID 生成链接。 */
- 'prevBtn' => '?string|array|bool=true',
- /* 下一个对象按钮链接,也可以通过数组指定按钮的所有属性,如果指定为 true,则自动从 $preAndNext 对象上获取 ID 生成链接。 */
- 'nextBtn' => '?string|array|bool=true'
- );
- /**
- * @var mixed[]
- */
- protected static $defineBlocks = array
- (
- 'header' => array(),
- 'title' => array(),
- 'main' => array('map' => 'content,section'),
- 'sections' => array(),
- 'side' => array('map' => 'tabs'),
- 'actions' => array('map' => 'btn'),
- 'toolbar' => array('map' => 'btnGroup,toolbar')
- );
- public static function getPageJS()
- {
- return file_get_contents(__DIR__ . DS . 'js' . DS . 'v1.js');
- }
- public static function getPageCSS()
- {
- return <<<'CSS'
- .detail-sections > * + * {margin-top: 16px}
- .detail-section.panel {--tw-ring-opacity: 0}
- .detail-section-title, .detail-section.panel .panel-heading {background: var(--color-canvas); position: sticky; top: 0; z-index: 2}
- .detail-section.panel .panel-heading {z-index: 1}
- .detail-section .detail-section .detail-section-title {z-index: 0}
- .detail-side > * + * {margin-top: 8px}
- .detail-side .tabs {padding: 12px 8px 12px 16px}
- .detail-side .tabs-header {position: sticky; top: 0;}
- .modal-dialog .detail-side .tabs-header {position: relative}
- .detail-side .tab-pane {padding: 0}
- .detail-sections .history-panel > .panel-heading,
- .detail-sections .history-panel > .panel-body {padding: 0.75rem 1.5rem}
- .detail-sections .history-panel-header > .listitem {padding: 0}
- .important-w-0 {width: 0!important;}
- .no-width {padding: 0!important; width: 0!important; overflow: hidden!important;}
- .detail-toggle {margin: 0!important;}
- .detail-toggle:hover {background-color: rgba(var(--color-border-rgb), var(--tw-bg-opacity)); transition-duration: .15s; transition-property: background-color; transition-timing-function: cubic-bezier(.4,0,.2,1);}
- .article > .files-list > li.file{white-space:normal;}
- .ai-task-status .status-doing {color: #FAAE1A;}
- .ai-task-status .status-done {color: #3883FA;}
- .ai-task-status .status-cancel, .ai-task-status .status-closed {color: #9EA3B0;}
- CSS;
- }
- protected function created()
- {
- global $app;
- $layout = $this->prop('layout');
- $objectType = $this->prop('objectType');
- $objectID = $this->prop('objectID');
- $object = $this->prop('object');
- if(is_null($layout))
- {
- $layout = isInModal() ? 'simple' : 'default';
- $this->setProp('layout', $layout);
- }
- if(!$objectType) $objectType = $app->rawModule;
- if(!$object) $object = data($objectType);
- if(!$objectID) $objectID = $object ? $object->id : data($objectType . 'ID');
- if(!$objectType || !$objectID || !$object)
- {
- $this->triggerError('The objectType, objectID or object property of widget "detail" is undefined.');
- }
- if(!$this->prop('objectType')) $this->setProp('objectType', $objectType);
- if(!$this->prop('objectID')) $this->setProp('objectID', $objectID);
- if(!$this->prop('object')) $this->setProp('object', $object);
- if($object)
- {
- if(!$this->hasProp('title')) $this->setProp('title', isset($object->name) ? $object->name : $object->title);
- if(!$this->hasProp('color') && isset($object->color)) $this->setProp('color', $object->color);
- if(!$this->hasProp('deleted') && isset($object->deleted)) $this->setProp('deleted', $object->deleted);
- }
- $parent = $this->prop('parent');
- if(!$parent && $object && isset($object->parent) && is_object($object->parent))
- {
- $parent = $object->parent;
- $this->setProp('parent', $parent);
- }
- if($parent)
- {
- if(!$this->hasProp('parentID')) $this->setProp('parentID', $parent->id);
- if(!$this->hasProp('parentTitle')) $this->setProp('parentTitle', isset($parent->name) ? $parent->name : $parent->title);
- if(!$this->hasProp('parentUrl')) $this->setProp('parentUrl', $parent->url);
- }
- if(!$this->hasProp('urlFormatter')) $this->setProp('urlFormatter', array('{id}' => $objectID));
- }
- /**
- * @param mixed[]|null $props
- */
- protected function buildBackBtn($props = null)
- {
- global $lang;
- $backBtn = $this->prop('backBtn');
- if($backBtn === false) return null;
- if(is_string($backBtn)) $backBtn = array('back' => $backBtn);
- elseif(!is_array($backBtn)) $backBtn = array();
- if($props) $backBtn = array_merge($backBtn, $props);
- return new backBtn
- (
- set::icon('back'),
- set::text($lang->goback),
- set::hint($lang->goback),
- setKey('backBtn'),
- set($backBtn)
- );
- }
- protected function buildTitle()
- {
- list($object, $objectID, $title, $titleProps, $color, $objectType, $parent, $parentID, $parentUrl, $parentTitle, $parentType, $parentTitleProps) = $this->prop(array('object', 'objectID', 'title', 'titleProps', 'color', 'objectType', 'parent', 'parentID', 'parentUrl', 'parentTitle', 'parentType', 'parentTitleProps'));
- $titleBlock = $this->block('title');
- $titleLeading = $this->block('titleLeading');
- return new entityTitle
- (
- setClass('min-w-0'),
- setKey('title'),
- set::id($objectID),
- set::object($object),
- set::title($title),
- set::titleClass('text-lg text-clip font-bold'),
- set::titleProps(array('title' => empty($titleProps) ? $title : $titleProps)),
- set::type($objectType),
- set::color($color),
- set::parentTitleClass('text-lg text-clip font-bold'),
- set::parent($parent),
- set::parentID($parentID),
- set::parentUrl($parentUrl),
- set::parentTitle($parentTitle),
- set::parentType($parentType),
- set::parentTitleProps($parentTitleProps),
- set::joinerClass('text-lg'),
- $titleLeading ? to::leading($titleLeading) : null,
- $titleBlock
- );
- }
- protected function buildToolbar()
- {
- $toolbar = $this->prop('toolbar');
- $toolbarBlock = $this->block('toolbar');
- if(!$toolbarBlock && !$toolbar) return null;
- return div
- (
- setClass('detail-toolbar'),
- setKey('toolbar'),
- $toolbar ? toolbar::create($toolbar, set::urlFormatter($this->prop('urlFormatter'))) : null,
- $toolbarBlock
- );
- }
- protected function buildHeader()
- {
- $isSimple = $this->prop('layout') === 'simple';
- return div
- (
- setClass('detail-header row gap-2 items-center flex-none'),
- setKey('header'),
- $isSimple ? null : $this->buildBackBtn(array('type' => 'primary-outline', 'class' => 'mr-2 size-md')),
- $this->buildTitle(),
- $this->block('header'),
- div(setClass('flex-auto')),
- $isSimple ? null : $this->buildToolbar()
- );
- }
- /**
- * @param mixed[]|callable|string|\zin\setting|\zin\node $item
- * @param string|null $title
- */
- protected function buildSection($item, $title = null)
- {
- if(is_callable($item)) $item = call_user_func($item, $title);
- elseif($item instanceof \Closure) $item = $item();
- if(is_null($title) && $item instanceof node) return $item;
- if($item instanceof setting) $item = $item->toArray();
- if(is_null($title) && isset($item['title']))
- {
- $title = $item['title'];
- unset($item['title']);
- }
- $titleActions = isset($item['titleActions']) ? $item['titleActions'] : null;
- $className = isset($item['className']) ? $item['className'] : null;
- if($titleActions) unset($item['titleActions']);
- return div
- (
- setClass('detail-section'),
- setKey($title),
- $title ? div
- (
- setClass('detail-section-title row items-center gap-2'),
- span(setClass('text-md py-1 font-bold'), $title),
- $titleActions ? toolbar::create($titleActions) : null
- ) : null,
- div(setClass("detail-section-content py-1 {$className}"), $item ? new content(is_array($item) ? set($item) : $item) : null)
- );
- }
- protected function buildMainSections()
- {
- global $app, $config;
- $sections = $this->prop('sections', []);
- if($config->edition != 'open' && empty($app->installing) && empty($app->upgrading)) $sections = $app->control->loadModel('flow')->buildExtendZinValue($sections, $this->prop('object'), 'info');
- $list = array();
- foreach($sections as $key => $item)
- {
- if($item === '-')
- {
- $list[] = hr();
- continue;
- }
- $list[] = $this->buildSection($item, is_string($key) ? $key : null);
- }
- return $list;
- }
- protected function buildHistory()
- {
- $history = $this->prop('history');
- if($history === false) return null;
- if(!is_array($history)) $history = array();
- return div
- (
- setClass('detail-sections canvas shadow rounded'),
- setKey('historyWrapper'),
- new history
- (
- set::className('detail-section overflow-visible'),
- set::objectType($this->prop('objectType')),
- set::objectID($this->prop('objectID')),
- set($history)
- )
- );
- }
- protected function buildActions()
- {
- global $app;
- $actions = $this->prop('actions');
- $actionsBlock = $this->block('actions');
- $isSimple = $this->prop('layout') === 'simple';
- if(!$actionsBlock && !is_array($actions)) return null;
- $toolbarProps = array_is_list($actions) ? array('items' => $actions) : $actions;
- if(!$isSimple)
- {
- $backBtn = $this->buildBackBtn(array('type' => 'ghost'));
- if(empty($toolbarProps['items'])) $toolbarProps['items'] = array($backBtn);
- else array_unshift($toolbarProps['items'], $backBtn, array('type' => 'divider'));
- }
- if(empty($toolbarProps['items']) && empty($actions)) return null;
- $toolbarClass = $app->getClientLang() == 'en' ? 'en-toolbar no-morph' : 'no-morph';
- return div
- (
- setClass('detail-actions center sticky mt-4 bottom-4 z-10'),
- setKey('actions'),
- div
- (
- setClass('bg-black text-fore-in-dark backdrop-blur bg-opacity-60 rounded p-1.5'),
- $toolbarProps ? toolbar
- (
- setClass($toolbarClass),
- set::urlFormatter($this->prop('urlFormatter')),
- set::btnType('ghost'),
- is_array($toolbarProps) ? set($toolbarProps) : null
- ) : null,
- $actionsBlock
- )
- );
- }
- protected function buildAITasks()
- {
- list($objectID, $objectType) = $this->prop(array('objectID', 'objectType'));
- if(!in_array($objectType, array('story', 'task', 'bug', 'testcase'))) return null;
- global $lang, $app, $config;
- if($config->edition == 'open') return null;
- $app->control->loadModel('aitask');
- jsVar('aiStatusList', $lang->aitask->statusList);
- if($objectType == 'testcase') $objectType = 'case';
- $users = $app->control->loadModel('user')->getPairs('noletter');
- $aiTasks = $app->control->aitask->getListByObject($objectType, $objectID);
- if(empty($aiTasks)) return null;
- $cols = $config->aitask->dtable->browse;
- unset($cols['objectType']);
- foreach($cols as $key => $col) $cols[$key]['sortType'] = false;
- $aiTasks = initTableData($aiTasks, $cols, $app->control->aitask);
- return panel
- (
- set::bodyClass('px-6 py-4'),
- div(setClass('text-md font-bold mb-2'), $lang->aitask->common),
- dtable(setID('aitasks'), set::cols($cols), set::data($aiTasks), set::userMap($users), set::onRenderCell(jsRaw('window.renderAITaskCell')), set::emptyTip($lang->aitask->noTask))
- );
- }
- protected function buildMain()
- {
- global $config;
- $mainSections = $this->buildMainSections();
- return div
- (
- setClass('detail-main flex-auto col gap-2 min-w-0'),
- $mainSections ? div
- (
- setClass('detail-sections canvas shadow rounded px-6 py-4'),
- setKey('main'),
- $mainSections,
- $this->block('main')
- ) : null,
- $this->block('sections'),
- !empty($config->enableAITeammate) && hasPriv('aitask', 'browse') ? $this->buildAITasks() : null,
- $this->children(),
- $this->buildHistory(),
- $this->buildActions()
- );
- }
- protected function buildTab($tab)
- {
- $title = isset($tab['title']) ? $tab['title'] : null;
- unset($tab['title']);
- return new tabPane
- (
- set::title($title),
- new content(set($tab))
- );
- }
- protected function buildTabs($group, $tabs)
- {
- $tabsView = new tabs
- (
- setClass('canvas rounded shadow'),
- setData('group', $group),
- setKey($group),
- set::collapse(true)
- );
- foreach($tabs as $tab)
- {
- $tabsView->add($this->buildTab($tab));
- }
- return $tabsView;
- }
- protected function buildTabsList()
- {
- global $app, $config;
- $tabs = $this->prop('tabs', []);
- if($config->edition != 'open' && empty($app->installing) && empty($app->upgrading)) $tabs = $app->control->loadModel('flow')->buildExtendZinValue($tabs, $this->prop('object'), 'basic');
- if(!$tabs) return null;
- $groups = array();
- foreach($tabs as $item)
- {
- $item = toArray($item);
- $group = isset($item['group']) ? $item['group'] : '';
- unset($item['group']);
- if(isset($groups[$group])) $groups[$group][] = $item;
- else $groups[$group] = array($item);
- }
- $views = array();
- foreach($groups as $groupName => $items)
- {
- $views[] = $this->buildTabs($groupName, $items);
- }
- return $views;
- }
- protected function buildSide()
- {
- return div
- (
- setClass('detail-side flex-none relative'),
- setStyle('width', $this->prop('sideWidth') . 'px'),
- setKey('side'),
- $this->buildTabsList(),
- $this->block('side'),
- div(
- setClass('detail-toggle h-full w-2 absolute top-0 flex justify-center items-center'),
- setStyle('left', '-.5rem'),
- btn(
- setClass('w-4 rounded-lg'),
- set::icon('chevron-right'),
- set::iconClass('text-sm text-gray'),
- on::click()
- ->do(<<<'JS'
- $('.detail-side').toggleClass('important-w-0');
- $('.tabs').toggleClass('no-width');
- $element.find('.icon')
- .toggleClass('icon-chevron-right')
- .toggleClass('icon-chevron-left');
- JS
- )
- )
- )
- );
- }
- protected function buildBody()
- {
- return div
- (
- setClass('detail-body row gap-2 items-start'),
- setKey('body'),
- $this->buildMain(),
- $this->buildSide()
- );
- }
- protected function buildPrevAndNext()
- {
- if($this->prop('layout') === 'simple') return null;
- list($linkCreator, $prevBtn, $nextBtn, $objectType) = $this->prop(array('linkCreator', 'prevBtn', 'nextBtn', 'objectType'));
- $preAndNext = data('preAndNext');
- $idKey = isset($preAndNext->idKey) ? $preAndNext->idKey : 'id';
- global $app;
- if(!$linkCreator && $preAndNext && ($prevBtn === true || $nextBtn === true))
- {
- $linkCreator = createLink($app->rawModule, $app->rawMethod, $objectType . 'ID={id}');
- }
- if($prevBtn === true && $preAndNext && $preAndNext->pre && $linkCreator)
- {
- $prevBtn = array();
- $objectID = $preAndNext->pre->$idKey;
- $prevBtn['url'] = str_replace('{id}', "{$objectID}", $linkCreator);
- $prevBtn['hint'] = "#{$objectID} " . (isset($preAndNext->pre->title) ? $preAndNext->pre->title : $preAndNext->pre->name);
- }
- elseif(is_string($prevBtn))
- {
- $prevBtn = array('url' => $prevBtn);
- }
- if($nextBtn === true && $preAndNext && $preAndNext->next && $linkCreator)
- {
- $nextBtn = array();
- $objectID = $preAndNext->next->$idKey;
- $nextBtn['url'] = str_replace('{id}', "{$objectID}", $linkCreator);
- $nextBtn['hint'] = "#{$objectID} " . (isset($preAndNext->next->title) ? $preAndNext->next->title : $preAndNext->next->name);
- }
- elseif(is_string($nextBtn))
- {
- $nextBtn = array('url' => $nextBtn);
- }
- $buttons = array();
- if(is_array($prevBtn))
- {
- $buttons[] = new btn
- (
- setClass('detail-prev-btn absolute top-0 left-0 inverse rounded-full w-12 h-12 center bg-opacity-40 backdrop-blur ring-0'),
- set::icon('angle-left icon-2x text-canvas'),
- setData('app', $app->tab),
- set($prevBtn)
- );
- }
- if(is_array($nextBtn))
- {
- $buttons[] = new btn
- (
- setClass('detail-next-btn absolute top-0 right-0 inverse rounded-full w-12 h-12 center bg-opacity-40 backdrop-blur ring-0'),
- set::icon('angle-right icon-2x text-canvas'),
- setData('app', $app->tab),
- set($nextBtn)
- );
- }
- if(!$buttons) return null;
- return div
- (
- setClass('detail-prev-next fixed top-0 left-0 bottom-0 right-0 z-10 pointer-events-none'),
- div
- (
- setClass('container relative pointer-events-auto'),
- setStyle(array('top' => '50%', 'margin' => '-24px auto auto')),
- $buttons
- )
- );
- }
- protected function build()
- {
- global $app;
- list($objectType, $objectID, $layout) = $this->prop(array('objectType', 'objectID', 'layout'));
- return div
- (
- setClass('detail-view col relative gap-2.5'),
- setData('id', $objectID),
- setData('type', $objectType),
- $this->buildHeader(),
- $this->buildBody(),
- $layout === 'simple' ? null : $this->buildPrevAndNext(),
- html($app->control->appendExtendCssAndJS('', '', $this->prop('object'))),
- js
- (
- 'const $doc = $(document)',
- 'const binddedKey = "zt.detail.bindded"',
- 'if($doc.data(binddedKey)) return',
- '$doc.on("keyup.detail.zt", e => {',
- 'if($(".modal.show").length) return',
- 'const $target = $(e.target);',
- 'if($target.is("input,textarea,select,[contenteditable]")) return',
- 'let $btn = null',
- 'if(e.keyCode === 37) $btn = $(".detail-prev-btn")',
- 'else if(e.keyCode === 39) $btn = $(".detail-next-btn")',
- 'if($btn && $btn.length) $btn[0].click()',
- '}).data(binddedKey, true)',
- '$doc.one("pageunmount.app", () => {$(document).off("keyup.detail.zt").removeData(binddedKey)})'
- )
- );
- }
- }
|