ConfigEditor.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <script setup lang="ts">
  2. import type { Config } from '@/api/config'
  3. import type { ChatComplicationMessage } from '@/api/openai'
  4. import type { Ref } from 'vue'
  5. import config from '@/api/config'
  6. import ngx from '@/api/ngx'
  7. import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
  8. import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
  9. import { ConfigHistory } from '@/components/ConfigHistory'
  10. import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
  11. import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
  12. import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
  13. import { formatDateTime } from '@/lib/helper'
  14. import { useSettingsStore } from '@/pinia'
  15. import ConfigName from '@/views/config/components/ConfigName.vue'
  16. import InspectConfig from '@/views/config/InspectConfig.vue'
  17. import { HistoryOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
  18. import { message } from 'ant-design-vue'
  19. import _ from 'lodash'
  20. const settings = useSettingsStore()
  21. const route = useRoute()
  22. const router = useRouter()
  23. // eslint-disable-next-line vue/require-typed-ref
  24. const refForm = ref()
  25. const origName = ref('')
  26. const addMode = computed(() => !route.params.name)
  27. const showHistory = ref(false)
  28. const basePath = computed(() => {
  29. if (route.query.basePath)
  30. return _.trim(route?.query?.basePath?.toString(), '/')
  31. else if (typeof route.params.name === 'object')
  32. return (route.params.name as string[]).slice(0, -1).join('/')
  33. else
  34. return ''
  35. })
  36. const data = ref({
  37. name: '',
  38. content: '',
  39. filepath: '',
  40. sync_node_ids: [] as number[],
  41. sync_overwrite: false,
  42. } as Config)
  43. const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
  44. const activeKey = ref(['basic', 'deploy', 'chatgpt'])
  45. const modifiedAt = ref('')
  46. const nginxConfigBase = ref('')
  47. const newPath = computed(() => {
  48. // 组合路径后解码显示
  49. const path = [nginxConfigBase.value, basePath.value, data.value.name]
  50. .filter(v => v)
  51. .join('/')
  52. return path
  53. })
  54. const relativePath = computed(() => (basePath.value ? `${basePath.value}/${route.params.name}` : route.params.name) as string)
  55. const breadcrumbs = useBreadcrumbs()
  56. async function init() {
  57. const { name } = route.params
  58. data.value.name = name?.[name?.length - 1] ?? ''
  59. origName.value = data.value.name
  60. if (!addMode.value) {
  61. config.get(relativePath.value).then(r => {
  62. data.value = r
  63. historyChatgptRecord.value = r.chatgpt_messages
  64. modifiedAt.value = r.modified_at
  65. const filteredPath = _.trimEnd(data.value.filepath
  66. .replaceAll(`${nginxConfigBase.value}/`, ''), data.value.name)
  67. .split('/')
  68. .filter(v => v)
  69. // Build accumulated path to maintain original encoding state
  70. let accumulatedPath = ''
  71. const path = filteredPath.map((segment, index) => {
  72. // Decode for display
  73. const decodedSegment = decodeURIComponent(segment)
  74. // Accumulated path keeps original encoding state
  75. if (index === 0) {
  76. accumulatedPath = segment
  77. }
  78. else {
  79. accumulatedPath = `${accumulatedPath}/${segment}`
  80. }
  81. return {
  82. name: 'Manage Configs',
  83. translatedName: () => decodedSegment,
  84. path: '/config',
  85. query: {
  86. dir: accumulatedPath,
  87. },
  88. hasChildren: false,
  89. }
  90. })
  91. breadcrumbs.value = [{
  92. name: 'Dashboard',
  93. translatedName: () => $gettext('Dashboard'),
  94. path: '/dashboard',
  95. hasChildren: false,
  96. }, {
  97. name: 'Manage Configs',
  98. translatedName: () => $gettext('Manage Configs'),
  99. path: '/config',
  100. hasChildren: false,
  101. }, ...path, {
  102. name: 'Edit Config',
  103. translatedName: () => origName.value,
  104. hasChildren: false,
  105. }]
  106. })
  107. }
  108. else {
  109. data.value.content = ''
  110. historyChatgptRecord.value = []
  111. data.value.filepath = ''
  112. const pathSegments = basePath.value
  113. .split('/')
  114. .filter(v => v)
  115. // Build accumulated path
  116. let accumulatedPath = ''
  117. const path = pathSegments.map((segment, index) => {
  118. // Decode for display
  119. const decodedSegment = decodeURIComponent(segment)
  120. // Accumulated path keeps original encoding state
  121. if (index === 0) {
  122. accumulatedPath = segment
  123. }
  124. else {
  125. accumulatedPath = `${accumulatedPath}/${segment}`
  126. }
  127. return {
  128. name: 'Manage Configs',
  129. translatedName: () => decodedSegment,
  130. path: '/config',
  131. query: {
  132. dir: accumulatedPath,
  133. },
  134. hasChildren: false,
  135. }
  136. })
  137. breadcrumbs.value = [{
  138. name: 'Dashboard',
  139. translatedName: () => $gettext('Dashboard'),
  140. path: '/dashboard',
  141. hasChildren: false,
  142. }, {
  143. name: 'Manage Configs',
  144. translatedName: () => $gettext('Manage Configs'),
  145. path: '/config',
  146. hasChildren: false,
  147. }, ...path, {
  148. name: 'Add Config',
  149. translatedName: () => $gettext('Add Configuration'),
  150. hasChildren: false,
  151. }]
  152. }
  153. }
  154. onMounted(async () => {
  155. await config.get_base_path().then(r => {
  156. nginxConfigBase.value = r.base_path
  157. })
  158. await init()
  159. })
  160. function save() {
  161. refForm.value?.validate().then(() => {
  162. config.save(addMode.value ? undefined : relativePath.value, {
  163. name: addMode.value ? data.value.name : undefined,
  164. base_dir: addMode.value ? basePath.value : undefined,
  165. content: data.value.content,
  166. sync_node_ids: data.value.sync_node_ids,
  167. sync_overwrite: data.value.sync_overwrite,
  168. }).then(r => {
  169. data.value.content = r.content
  170. message.success($gettext('Saved successfully'))
  171. if (addMode.value) {
  172. router.push({
  173. path: `/config/${data.value.name}/edit`,
  174. query: {
  175. basePath: basePath.value,
  176. },
  177. })
  178. }
  179. else {
  180. data.value = r
  181. }
  182. })
  183. })
  184. }
  185. function formatCode() {
  186. ngx.format_code(data.value.content).then(r => {
  187. data.value.content = r.content
  188. message.success($gettext('Format successfully'))
  189. }).catch(r => {
  190. message.error($gettext('Format error %{msg}', { msg: r.message ?? '' }))
  191. })
  192. }
  193. function goBack() {
  194. // Keep original path with encoding state
  195. const encodedPath = basePath.value || ''
  196. router.push({
  197. path: '/config',
  198. query: {
  199. dir: encodedPath || undefined,
  200. },
  201. })
  202. }
  203. function openHistory() {
  204. showHistory.value = true
  205. }
  206. </script>
  207. <template>
  208. <ARow :gutter="16">
  209. <ACol
  210. :xs="24"
  211. :sm="24"
  212. :md="18"
  213. >
  214. <ACard :title="addMode ? $gettext('Add Configuration') : $gettext('Edit Configuration')">
  215. <template #extra>
  216. <AButton
  217. v-if="!addMode && data.filepath"
  218. type="link"
  219. @click="openHistory"
  220. >
  221. <template #icon>
  222. <HistoryOutlined />
  223. </template>
  224. {{ $gettext('History') }}
  225. </AButton>
  226. </template>
  227. <InspectConfig
  228. v-show="!addMode"
  229. />
  230. <CodeEditor v-model:content="data.content" />
  231. <FooterToolBar>
  232. <ASpace>
  233. <AButton @click="goBack">
  234. {{ $gettext('Back') }}
  235. </AButton>
  236. <AButton @click="formatCode">
  237. {{ $gettext('Format Code') }}
  238. </AButton>
  239. <AButton
  240. type="primary"
  241. @click="save"
  242. >
  243. {{ $gettext('Save') }}
  244. </AButton>
  245. </ASpace>
  246. </FooterToolBar>
  247. </ACard>
  248. </ACol>
  249. <ACol
  250. :xs="24"
  251. :sm="24"
  252. :md="6"
  253. >
  254. <ACard class="col-right">
  255. <ACollapse
  256. v-model:active-key="activeKey"
  257. ghost
  258. >
  259. <ACollapsePanel
  260. key="basic"
  261. :header="$gettext('Basic')"
  262. >
  263. <AForm
  264. ref="refForm"
  265. layout="vertical"
  266. :model="data"
  267. :rules="{
  268. name: [
  269. { required: true, message: $gettext('Please input a filename') },
  270. { pattern: /^[^\\/]+$/, message: $gettext('Invalid filename') },
  271. ],
  272. }"
  273. >
  274. <AFormItem
  275. name="name"
  276. :label="$gettext('Name')"
  277. >
  278. <AInput v-if="addMode" v-model:value="data.name" />
  279. <ConfigName v-else :name="data.name" :dir="data.dir" />
  280. </AFormItem>
  281. <AFormItem
  282. v-if="!addMode"
  283. :label="$gettext('Path')"
  284. >
  285. {{ decodeURIComponent(data.filepath) }}
  286. </AFormItem>
  287. <AFormItem
  288. v-show="data.name !== origName"
  289. :label="addMode ? $gettext('New Path') : $gettext('Changed Path')"
  290. required
  291. >
  292. {{ decodeURIComponent(newPath) }}
  293. </AFormItem>
  294. <AFormItem
  295. v-if="!addMode"
  296. :label="$gettext('Updated at')"
  297. >
  298. {{ formatDateTime(modifiedAt) }}
  299. </AFormItem>
  300. </AForm>
  301. </ACollapsePanel>
  302. <ACollapsePanel
  303. v-if="!settings.is_remote"
  304. key="deploy"
  305. :header="$gettext('Deploy')"
  306. >
  307. <NodeSelector
  308. v-model:target="data.sync_node_ids"
  309. hidden-local
  310. />
  311. <div class="node-deploy-control">
  312. <div class="overwrite">
  313. <ACheckbox v-model:checked="data.sync_overwrite">
  314. {{ $gettext('Overwrite') }}
  315. </ACheckbox>
  316. <ATooltip placement="bottom">
  317. <template #title>
  318. {{ $gettext('Overwrite exist file') }}
  319. </template>
  320. <InfoCircleOutlined />
  321. </ATooltip>
  322. </div>
  323. </div>
  324. </ACollapsePanel>
  325. <ACollapsePanel
  326. key="chatgpt"
  327. header="ChatGPT"
  328. >
  329. <ChatGPT
  330. v-model:history-messages="historyChatgptRecord"
  331. :content="data.content"
  332. :path="data.filepath"
  333. />
  334. </ACollapsePanel>
  335. </ACollapse>
  336. </ACard>
  337. </ACol>
  338. <ConfigHistory
  339. v-model:visible="showHistory"
  340. v-model:current-content="data.content"
  341. :filepath="data.filepath"
  342. />
  343. </ARow>
  344. </template>
  345. <style lang="less" scoped>
  346. .col-right {
  347. position: sticky;
  348. top: 78px;
  349. :deep(.ant-card-body) {
  350. max-height: 100vh;
  351. overflow-y: scroll;
  352. }
  353. }
  354. :deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
  355. padding: 0;
  356. }
  357. :deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
  358. padding: 0 0 10px 0;
  359. }
  360. .overwrite {
  361. margin-right: 15px;
  362. span {
  363. color: #9b9b9b;
  364. }
  365. }
  366. .node-deploy-control {
  367. display: flex;
  368. justify-content: flex-end;
  369. margin-top: 10px;
  370. align-items: center;
  371. }
  372. </style>